Problem Summary
After a successful payment, your application must receive a Stripe webhook event, map it to the correct user, update the user's access tier, and provision the appropriate permissions all reliably, even if the initial webhook delivery fails. When any step in this chain breaks silently, the user pays but nothing changes on their account. This incident type is particularly painful because the customer sees a success page and expects their upgrade to be instant.
Symptoms
- User reports being charged but still sees the free tier UI after refreshing
- Stripe shows payment_intent.succeeded but no corresponding access change in your logs
- User is looked up by email in webhook handler but lookup returns null due to case sensitivity mismatch
- Webhook fires, user is found, but database transaction throws and is silently swallowed
- Access tier updated in database but application server has cached the old tier for 30 minutes
- Multiple concurrent webhook deliveries result in a race condition some access updates overwrite others
- Handler processes checkout.session.completed but never reads the correct metadata field to identify the user
- Access control middleware reads from a stale in-memory cache, not the live database
Business Impact
Users who pay and do not receive access have the worst possible first impression of your premium product. The conversion funnel inverted: you captured their payment intent, closed the sale, and then failed to deliver. Refund rates, chargeback rates, and negative reviews all spike. For SaaS products, this pattern often causes users to request refunds immediately rather than waiting for support to manually provision their account.
Root Cause
The most common root causes, roughly in order of frequency: (1) the webhook handler looks up users by email, but the email in the Stripe event does not match the email in the database (different casing, different account); (2) the handler catches an exception but does not re-throw it, causing Stripe to receive HTTP 200 and never retry even though nothing was provisioned; (3) the access tier is updated in the database but an application-layer cache is not invalidated.
Never look up users by email in webhook handlers. Email addresses can change, can be case-mismatched, or can belong to multiple accounts. Always look up by Stripe customer ID (cus_XXXXX), stored in your database at customer creation time.
Diagnosis
Step 1 Confirm Webhook Is Received and Handler Runs
Add structured logging at the very first line of your webhook handler, before any business logic. This confirms whether the event is arriving at your server.
import Stripe from "stripe";
export async function handlePaymentSucceeded(event: Stripe.Event) {
// Log immediately before any logic that could fail
console.log(
JSON.stringify({
level: "info",
message: "Stripe event received",
eventId: event.id,
eventType: event.type,
timestamp: new Date().toISOString(),
})
);
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const stripeCustomerId = paymentIntent.customer as string;
const userId = paymentIntent.metadata?.userId;
if (!userId) {
console.error(
`[Access] No userId in metadata for payment intent ${paymentIntent.id}`
);
// Still return 200 this is a data problem, not a delivery problem
return;
}
// Look up by Stripe customer ID or metadata userId NOT by email
const user = await db.users.findFirst({
where: {
OR: [
{ stripeCustomerId },
{ id: userId },
],
},
});
if (!user) {
console.error(
`[Access] User not found: customerId=${stripeCustomerId}, userId=${userId}`
);
throw new Error(`User not found for payment intent ${paymentIntent.id}`);
// Throw causes HTTP 500 → Stripe retries delivery
}
console.log(`[Access] Provisioning access for user ${user.id}`);
await provisionAccess(user.id, paymentIntent);
}Step 2 Link Stripe Customer ID at Registration Time
The reliable way to map Stripe events to users is to create or retrieve a Stripe Customer at registration time and store the customer ID. Every Stripe event includes the customer ID.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function getOrCreateStripeCustomer(user: {
id: string;
email: string;
name: string;
}): Promise<string> {
// Check if we already have a customer ID
const existing = await db.users.findUnique({
where: { id: user.id },
select: { stripeCustomerId: true },
});
if (existing?.stripeCustomerId) {
return existing.stripeCustomerId;
}
// Create a new Stripe customer with your internal user ID in metadata
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId: user.id, // critical allows webhook → user mapping
},
});
await db.users.update({
where: { id: user.id },
data: { stripeCustomerId: customer.id },
});
return customer.id;
}Step 3 Implement Access Provisioning with Idempotency
import Stripe from "stripe";
type Tier = "free" | "pro" | "enterprise";
const PRICE_TO_TIER: Record<string, Tier> = {
[process.env.PRICE_ID_PRO!]: "pro",
[process.env.PRICE_ID_ENTERPRISE!]: "enterprise",
};
export async function provisionAccess(
userId: string,
paymentIntent: Stripe.PaymentIntent
): Promise<void> {
const priceId = paymentIntent.metadata?.priceId;
if (!priceId) {
throw new Error(
`No priceId in payment intent metadata: ${paymentIntent.id}`
);
}
const tier = PRICE_TO_TIER[priceId];
if (!tier) {
throw new Error(`Unknown priceId: ${priceId}`);
}
// Idempotency: check if this payment intent was already processed
const alreadyProcessed = await db.accessGrants.findUnique({
where: { stripePaymentIntentId: paymentIntent.id },
});
if (alreadyProcessed) {
console.log(
`[Access] Already provisioned for payment intent ${paymentIntent.id}`
);
return;
}
await db.$transaction(async (tx) => {
await tx.users.update({
where: { id: userId },
data: {
tier,
tierActivatedAt: new Date(),
},
});
await tx.accessGrants.create({
data: {
userId,
tier,
stripePaymentIntentId: paymentIntent.id,
grantedAt: new Date(),
},
});
});
// Invalidate any cached session / access tokens
await invalidateUserCache(userId);
console.log(`[Access] User ${userId} upgraded to ${tier}`);
}Step 4 Attach PriceId and UserId to PaymentIntent Metadata
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function createUpgradePaymentIntent(
userId: string,
priceId: string
): Promise<Stripe.PaymentIntent> {
const stripeCustomerId = await getOrCreateStripeCustomer({ id: userId });
const price = await stripe.prices.retrieve(priceId);
const intent = await stripe.paymentIntents.create({
amount: price.unit_amount!,
currency: price.currency,
customer: stripeCustomerId,
metadata: {
userId,
priceId,
},
automatic_payment_methods: {
enabled: true,
},
});
return intent;
}Step 5 Invalidate Application Caches After Provisioning
Even with a correctly updated database, users can still see stale access levels if your application caches session data. Invalidate any Redis, in-memory, or session-store cached access after provisioning.
import { redis } from "../lib/redis";
export async function invalidateUserCache(userId: string): Promise<void> {
// Invalidate Redis-cached user session
const sessionKey = `user:session:${userId}`;
const tierKey = `user:tier:${userId}`;
await redis.del(sessionKey, tierKey);
console.log(`[Cache] Invalidated cache for user ${userId}`);
}Production Failure Scenarios
Scenario 1 Exception Swallowed, HTTP 200 Returned
The webhook handler wraps business logic in try/catch and catches the database exception, logs it, but still returns HTTP 200. Stripe receives 200 and considers the delivery successful. No retry is scheduled. The user never gets access, and the system shows no evidence of a problem.
Scenario 2 Email Lookup Fails Due to Case Mismatch
User registered with 'User@Example.com'. Stripe stores the customer email as 'user@example.com'. Webhook handler does a case-sensitive SQL WHERE email = ? and finds nothing. Handler logs 'user not found' and returns 200. Access is never granted.
Scenario 3 Cache Not Invalidated After Access Grant
Database is updated correctly. User refreshes the page. The Next.js server reads from a 30-minute Redis cache that was populated before the upgrade. The user still sees the free tier. They think the upgrade failed, open a support ticket, and consider requesting a refund.
Prevention Checklist
- Always store the Stripe customer ID against your user record at registration time
- Look up users by stripeCustomerId or metadata.userId never by email
- Attach userId and priceId to every PaymentIntent's metadata field
- Re-throw exceptions in webhook handlers so Stripe receives a non-2xx and retries
- Implement idempotency by recording processed payment intent IDs
- Invalidate all user-related caches immediately after provisioning access
- Run a daily reconciliation job comparing Stripe charges to access grants
- Log the event.id, user.id, and outcome of every provisioning attempt
Resolution Summary
Store Stripe customer IDs at registration. Pass userId and priceId in PaymentIntent metadata. Look up users by customer ID in webhook handlers. Re-throw exceptions to trigger Stripe retries. Implement idempotency on payment intent IDs. Invalidate caches after provisioning. Add a daily reconciliation check as a safety net.
Lessons Learned
- Email is a mutable, case-insensitive, non-unique identifier never use it as a Stripe-to-user mapping key
- Swallowed exceptions that return HTTP 200 are the most dangerous bug pattern in webhook handlers
- Access control caches must be invalidated synchronously as part of the provisioning transaction
- Metadata on PaymentIntents is the most reliable way to carry context from payment creation to webhook delivery
- Idempotency + retry = exactly-once semantics, which is what payment systems require
Conclusion
The gap between 'payment succeeded' and 'user upgraded' is bridged by reliable webhook delivery, correct user lookup, exception propagation, idempotency, and cache invalidation. Each of these is a distinct failure point. Address all five and the access provisioning pipeline becomes robust enough to handle Stripe retries, concurrent deliveries, and deployment-time cache staleness.