The handler that “looks right” in code review:
case "checkout.session.completed": {
const session = event.data.object;
await db.update(users)
.set({ plan: "pro", stripeCustomerId: session.customer })
.where(eq(users.stripeCustomerId, session.customer));
break;
}
Stripe shows paid. Your UPDATE matches zero rows. No throw. Entitlement never flips. Support gets “I paid and nothing happened.”
Why the race exists
Checkout and Customer are not one synchronous write from your app’s point of view. Depending on how you create the session (customer vs customer_email, Checkout vs Elements, whether you created the Customer via API first), checkout.session.completed can land while:
- you only stored an email pending verification
customer.createdis still in flight- your upsert keyed on a different id than
session.customer
Docs show the happy order. Production shows concurrent delivery. Same class of bug as payment providers firing “activated” before “created” — different objects, same assumption: the row I need is already there.
The fix shape
Treat checkout.session.completed as able to create or update:
- upsert by
stripeCustomerId/ email with plan entitlement - if you require a local user id, map
client_reference_idor metadata you set at session create - never rely on a prior webhook having succeeded
Idempotency still matters: Stripe will retry. Key fulfillment on session.id (or the subscription/payment intent id you treat as the grant), not on “run update once.”
Prove it on purpose
Happy-path fixtures insert the customer, then fire completed. That never reproduces the miss.
With the FetchSandbox MCP server against a Stripe sandbox:
./fetchsandbox run checkout.session.completed with a customer id that
does not exist in my local DB yet — assert my handler upserts entitlement
instead of no-op updating zero rows. Then replay the same session.id and
assert I do not double-grant.
The receipt shows the miss (or the fix). Reading the diff cannot.
The rule
If fulfillment does UPDATE … WHERE customer = session.customer with no insert path, you have this bug. You just have not lost the race in production yet.