Skip to main content

Stripe

Stripe checkout.session.completed can beat your customer row

checkout.session.completed often arrives before your app has inserted the customer. If fulfillment assumes the row exists, you silently skip entitlement — while Stripe shows paid.

FetchSandbox EngineeringFetchSandbox Engineering

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.created is 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_id or 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.