Stripe Webhook Retries, Duplicates, and Out-of-Order Events: The Idempotency Playbook
Stripe retries webhooks for 3 days, sends duplicates, and delivers out of order. The idempotent handler pattern that protects billing logic.

The 30-second answer
Stripe webhooks are at-least-once and unordered: the same event can arrive twice, related events can arrive in the wrong order, and if your endpoint errors, Stripe retries for up to three days. The correct handler shape is: verify the signature, respond 2xx fast, dedupe on the event ID, and process asynchronously — never trust the payload to be unique, current, or in sequence.
If your billing logic has ever double-emailed a customer, double-granted access, or resurrected a canceled subscription, one of these three behaviors is why. This post is the fix. It's also the difference between a dunning system you trust and one that's quietly freelancing.
Behavior 1: retries (your endpoint's fault, Stripe's persistence)
When your endpoint returns anything that isn't a 2xx — a 500, a timeout, a connection drop — Stripe doesn't give up. It retries the event for up to three days in live mode, spacing attempts further apart as time passes. Test mode is less patient: a few hours.
This is a gift when your server had a bad deploy at 2 AM. It's a trap when your handler throws on purpose — say, because it hit an event type it doesn't recognize. That exception doesn't just drop one event. It schedules that event to keep arriving, over and over, for days, each attempt failing the same way. If your error tracking has ever shown the same webhook error repeating for 72 hours straight, that's what you were looking at.
- •Rule 1: return 2xx for events you don't handle. 'Received and ignored' is a success response. Save the errors for actual failures.
- •Rule 2: respond before you process. Verify the signature, stash the event, return 200, THEN do the work. A handler that does heavy lifting before responding will time out eventually, and a timeout looks like a failure to Stripe — cue three days of retries for an event you already half-processed.
- •Rule 3: if Stripe emails you that your endpoint was disabled, act on it. Extended failure gets endpoints auto-disabled, and events sent during the outage don't retry forever — you'll need to replay them or reconcile via the API.
Behavior 2: duplicates (at-least-once means at-least-twice, eventually)
Stripe's delivery guarantee is at-least-once. In practice, most events arrive exactly once — until the day a retry fires at the exact moment your slow endpoint actually succeeded, or a network blip makes Stripe unsure whether you got it. Unsure means send again. Now invoice.payment_succeeded has arrived twice, and if your handler isn't ready for that, your customer just got two 'payment received, welcome back!' emails and your MRR math counted them twice.
The fix is boring and non-negotiable: idempotency via event ID. Every event has a unique id (starts with evt_). Before processing, check if you've seen it. If yes, return 200 and do nothing. If no, process it and record the ID. A database table with a unique constraint on event ID is the classic implementation — the constraint itself is your race-condition protection when two duplicates arrive simultaneously.
"A webhook handler without idempotency isn't a handler. It's a slot machine that pays out in duplicate emails."
Behavior 3: out-of-order delivery (time is an illusion)
This is the subtle one. customer.subscription.created and customer.subscription.updated can arrive in either order. A dunning system that applies events blindly can end up in a state that never existed: the 'updated' event (with an older status) landing after the newer one, rolling your records backwards. Your dashboard says canceled. Your database says active. Your customer keeps access they stopped paying for — or loses access they did pay for. Both have happened to real companies.
- •Option A — timestamp check: compare the event's created timestamp against what you've stored, and discard events older than your current state. Cheap, mostly works.
- •Option B — fetch fresh state: treat every event as a doorbell, not a message. When invoice.payment_failed rings, don't trust the payload — call the Stripe API for the invoice's current state and act on that. Slightly more API calls, but you're always acting on the truth as it exists right now, not as it existed when the event was emitted.
For billing-critical logic, I prefer option B. An event payload is a photograph; the API is the live scene. When the stakes are 'does this person have access to the thing they pay for,' work from the live scene.
Testing this properly
The Stripe CLI is your friend here: stripe trigger invoice.payment_failed fires a real-shaped test event at your local endpoint. What it won't do is simulate the nasty parts — duplicates, reordering, retries. For those you have to be deliberate: send the same event twice by hand (your dedupe should shrug), replay an older event after a newer one (your timestamp check or API fetch should win), and kill your endpoint mid-test to watch the retry behavior pile up in your Stripe dashboard's event log.
That dashboard event log is criminally underused, by the way. Every event Stripe has sent you, every delivery attempt, every response code your endpoint returned — it's all there. When billing logic misbehaves in production, the answer to 'what actually happened' is usually sitting in that log. I've debugged more dunning weirdness from the event history than from my own application logs.
The 202 vs 200 debate (skip it)
You'll see advice about returning 202 Accepted versus 200 OK, about queue systems, about exactly-once delivery frameworks. For a SaaS billing webhook at indie scale, almost all of it is overkill. The five-step shape below — verify, dedupe, respond fast, fetch fresh state, log — handles the failure modes that actually hurt you. Save the event-sourcing architecture for when you're processing thousands of events an hour. Before then, a Postgres unique constraint and a background job beat a message queue you'll spend a week configuring.
One exception: if your handler does anything slow and synchronous — sending email via an API, generating PDFs, calling other services — that work belongs in a background job from day one. Not for scale, but for the timeout trap from behavior 1. Slow handlers look like failing handlers, and failing handlers get three days of retries. A webhook handler is a mailroom, not an office: sort fast, deliver the work elsewhere.
Putting it together: the handler shape
- •1. Verify the signature with your endpoint's signing secret. Forged events are a real attack; I wrote a whole post on the secret.
- •2. Dedupe on event ID. Seen it? 200, done.
- •3. Respond 2xx immediately. Everything below happens async.
- •4. For state-changing events, fetch current state from the API rather than trusting payload freshness.
- •5. Log everything. When a customer emails 'you charged me but I have no access,' your event log is the difference between a 5-minute fix and an afternoon of archaeology.
Why I care enough to write 1,500 words on webhook plumbing: StayPaid's entire recovery engine hangs off these events. invoice.payment_failed starts a recovery sequence, invoice.payment_succeeded stops one, customer.subscription.deleted closes the case — and every one of those decisions is only as good as the handler's discipline about retries, duplicates, and ordering. If you're building your own dunning on webhooks, get these three behaviors right before you write a single recovery email. The emails are the easy part. The event handling is where billing systems lie to you.
FAQ
How long does Stripe retry failed webhooks?
Up to three days in live mode, with retries spread further apart over time (exponential backoff). In test mode the retry window is much shorter — a few hours. A retry stops as soon as your endpoint returns any 2xx response.
Can Stripe send the same webhook event twice?
Yes. Delivery is at-least-once: network hiccups, timeouts, and slow responses can all cause the same event to arrive multiple times. Your handler must be idempotent — dedupe on the event ID before doing anything.
Do Stripe webhooks arrive in order?
No. Related events can arrive out of order — an updated event can land before the created event. Either check event timestamps before overwriting state, or fetch the current object from the Stripe API instead of trusting the payload.
Why does my endpoint get disabled in Stripe?
If your endpoint fails repeatedly over an extended period, Stripe can automatically disable it and stop sending events. You'll get notified by email — and any events sent while it was down need to be replayed or fetched manually.
Keep reading
Robert
Founder at StayPaid
Want to recover failed payments like a founder?
Start Free — First 3 recoveries