Handling Out-of-Order Stripe Webhooks Correctly
A customer's card fails. Smart Retries picks it up an hour later and clears it. Your dunning system should mark them recovered and move on. Instead, three days later, they get a cancellation email for a subscription that's been active the whole time.
Nothing crashed. No code threw an error. A stale invoice.payment_failed event, delayed somewhere in Stripe's infrastructure, arrived after the invoice.payment_succeeded event that resolved it — and the handler applied it anyway, because it had no way of knowing it was looking at the past.
Most people building on Stripe know two things about webhooks going in: Stripe retries failed deliveries, so your handler needs to be idempotent, and events don't arrive in the order they happened, so you can't assume the last thing you received is the newest thing that's true. Fine. Most teams even handle the first one — dedupe on event.id, unique constraint, done. It's the second one where the standard advice quietly falls apart.
The fix everyone reaches for doesn't work
Search "Stripe webhook ordering" and you'll get the same answer everywhere: compare event.created timestamps, keep the newest one, discard the rest. It sounds right. It's also the fix Stripe's own documentation warns against — two events can share the same created second, and under any real load, they sometimes do. You can build an entire ordering guard around a timestamp and still get overtaken by an event that technically ties with the one you already applied.
That's the part most write-ups skip. They tell you events arrive out of order, hand you a timestamp comparison, and move on like the problem's solved. It isn't. You've swapped "trust arrival order" for "trust a clock that can't resolve ties" — a smaller bug, same category.
Deduping and ordering are two different problems
I keep seeing these treated as one fix, and they're not.
Deduplication asks: have I already processed this exact event? A unique constraint on event.id — insert, ON CONFLICT DO NOTHING — answers that cleanly. It's the table I put in front of every Stripe integration I build: raw events land in webhook_events first, keyed by event ID, before anything downstream even sees them.
Ordering asks a harder question: is the event in front of me the current truth, or a straggler describing a state that's already been superseded? Deduplication can't answer that, because a stale event is still, by definition, an event you've never seen before.
What actually holds up
For anything that gates money or access — subscription status, whether an invoice is paid, whether a customer keeps their seats — don't trust the payload at all. Treat the webhook as a doorbell, not a delivery. It tells you something changed on sub_xyz; go ask Stripe what sub_xyz looks like right now, and write that. A payload that's three retries and forty minutes old can't tell you the current state, but a fresh GET on the subscription always can.
For state you compute yourself and don't want to re-derive on every event — a dunning_state field, say — guard the transition instead of trusting whatever triggered it. Decide which state changes are legal and reject the rest: a subscription already marked recovered doesn't get demoted back to at_risk by a payment_failed event describing something older than the payment_succeeded you recorded. You're not asking which event is newer. You're asking whether this event makes sense given what you already know — a question your own state machine can answer even when Stripe's clock can't.
That's the same principle behind how I structure a dunning engine as a state machine rather than a chain of event reactions — the state machine ends up doing more of the real work than the webhook ever does.
Where this bites in practice
The expiring-card system I run leans on the same instinct, for lower stakes: it fires a three-email sequence 30 days before a card expires, and has to stop the moment the card gets fixed, no matter which of several possible events tells it so. I wrote up how that one's built. It doesn't ask whether this was the last event it got. It asks whether the thing it's about to act on is still true.
If you haven't set up webhook handling yet, start here — this piece assumes signatures are already verified and a 200 is going back fast, and picks up where that leaves off.
Why you won't see this in testing
Stripe's CLI forwards events to your local machine one at a time, over a connection that isn't fighting for bandwidth — nothing's contending for the slot, so everything arrives in the order it was sent. Ship the same handler to production, add a few hundred milliseconds of cold start or a brief outage that lets events queue up on Stripe's side, and the guarantees your local testing gave you evaporate. This bug doesn't show up in staging. It shows up three weeks after launch, on a Tuesday, to one customer — and it takes a support ticket to even notice.
Build the handler assuming Stripe will eventually hand you events in the wrong order. It will. That assumption costs you a re-fetch and a guard clause up front. Skipping it costs you a customer who churned because your system told them they had, even after they'd already paid.
Hi, I'm Mathew — I build Stripe billing infrastructure, dunning systems, and payment integrations that keep SaaS revenue from leaking. If this kind of thing is useful to you, I write more of it over on Medium and post updates on LinkedIn.
I help SaaS founders recover up to 50-60% of their “failed payment” churn through technical Stripe & Klaviyo optimization.