Blog

Your Stripe webhook will fire twice

Re-fetching the object on every event makes duplicate deliveries harmless, which is why this codebase has no table of processed event ids.

Someone pays, and your handler runs twice. Sometimes seconds apart, sometimes weeks apart when Stripe retries an endpoint that timed out. You find it because a customer got two receipt emails, or because a row in your database went backwards: a subscription that says active in the dashboard says past_due in your app, and nothing you can find in your code ever wrote past_due after active.

Both symptoms have the same cause, and it is not the duplicate delivery.

The fix everybody reaches for first

Stripe's own guidance is to record the ids of events you have processed, so you can recognise a duplicate and skip it. So you add a table:

create table stripe_events (id text primary key, processed_at timestamptz);

Insert the event id at the top of the handler, and if the insert conflicts, you have seen this one before and you return early.

This works, and it is still the wrong first move, because it only fixes the first symptom. Deduplication makes each event run once. It does nothing about the order they run in, and out-of-order delivery is not an edge case: Stripe sends customer.subscription.updated on every status change, and there is no promise that the one from 10:02 reaches you after the one from 10:01. Dedupe the events perfectly and the older payload still lands last and still wins.

The row went backwards because you wrote the payload. That is the actual bug, and the event-id table is not a fix for it. It is a fix for a symptom that sits next to it.

Treat the event as a trigger, not as data

An event means go look again. Take the id off it, re-fetch the object from Stripe, and write what the API hands back. Two of the four branches in apps/api/src/routes/stripe/post-webhook.ts, with the other two below:

switch (event.type) {
  case 'checkout.session.completed':
  case 'checkout.session.async_payment_succeeded':
    await recordCheckoutSession(event.data.object.id)
    break

  case 'customer.subscription.created':
  case 'customer.subscription.updated':
  case 'customer.subscription.deleted':
    await syncSubscription(event.data.object.id)
    break
}

Every branch passes an id. Not event.data.object, the id off it. The functions behind those calls fetch the object themselves.

That one change makes every write convergent. Two deliveries of the same event both fetch the current object and write the same thing. An old event racing a newer one also fetches the current object, so the stale one writes today's state rather than yesterday's. The last writer is correct by construction, and it stops mattering how many times an event arrives or in what order.

Which is why we never added the stripe_events table. The property it buys, that a duplicate does no harm, is already unconditional once the handler re-fetches. Adding it would have meant carrying a row per event forever to guarantee something the design guarantees for free.

Notice the three customer.subscription.* events collapsing into one branch, too. They needed to be three branches only while each one wrote different fields out of its own payload. Once the handler re-fetches, there is nothing left for them to do differently.

The part re-fetching does not cover

Convergence is a property of database writes. Sending an email twice sends two emails, and inviting someone to a repo twice sends two invites. Those do not converge, so they need a real exactly-once guarantee.

You do not need a queue for it. You need a nullable column and one UPDATE:

const [claimed] = await db
  .update(purchases)
  .set({ stripeChargeId })
  .where(
    and(
      eq(purchases.stripePaymentIntentId, stripePaymentIntentId),
      isNull(purchases.stripeChargeId),
    ),
  )
  .returning({ id: purchases.id, email: purchases.email })

if (claimed === undefined) return

A row that comes back is yours and you send the email. A row that does not come back was claimed by another delivery, so you return. Postgres does the mutual exclusion, the claim is the same write that records the charge id, and there is no second table and no worker. The receipt in this codebase and the GitHub invite that fulfils a purchase both work this way.

The re-fetch pays off a second time here. By the time a redelivery arrives weeks later, the charge may have been refunded, and the fetched charge says so:

const charge = await stripe().charges.retrieve(stripeChargeId)

if (charge.refunded) return

A handler working off the original payload has no way to know that.

Answer 2xx last

One more thing, because it is the failure that costs you a customer rather than an email. Do not wrap each branch in a try that answers 200 anyway. That turns a failed write into an event Stripe marks delivered and never sends again: someone paid, no row exists, and there is nothing left to replay.

Let it throw. An error reaches your error middleware, Stripe sees a non-2xx, and the event stays queued for redelivery, which is safe now precisely because the retry converges.

An event type you do not handle is the opposite case and genuinely deserves a 200. Stripe sends many, and 4xx-ing them makes Stripe treat the endpoint as unhealthy and back off the events you care about.

What it costs

One extra API call to Stripe per event. At most volumes that is nothing, and at high volume the event-id table becomes worth adding, with the event id as its natural primary key. It is an optimisation on top of a correct handler rather than a substitute for one.

The other cost is real and worth knowing: a dropped event leaves your state stale until the next event arrives or someone re-triggers it by hand. Nothing here reconciles on a timer. If you need that, it is a job that lists objects changed since a timestamp, and it is separate work.

Fast Forward Dev is a SaaS boilerplate built this way throughout: TanStack Start, Express 5, Clerk, Stripe and Postgres, sold as the same repo that runs this site. The handler and the claim are written up in How it works: webhooks, and the price is on the front page.