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.