Stripe webhook duplicate events: stop the double charge

A customer gets charged twice for the same thing. Your code only meant to charge them once. The bug isn't in your charge logic — it's that Stripe delivered the same webhook event twice, and your handler did the work both times.
Duplicate events aren't a Stripe malfunction; they're a guarantee you have to design around. Once you understand why they happen, making your handler immune is a small, permanent fix.
Why the same event arrives twice
Stripe's webhook delivery is at-least-once, not exactly-once. That's a deliberate reliability choice: Stripe would rather send an event one extra time than risk you never getting it. Two normal situations produce a duplicate:
- at-least-once
- Stripe's delivery guarantee — duplicates are expected
- retries
- a slow 2xx can arrive after Stripe already retried
- evt_...
- the event id — stable across every redelivery
- 24h
- how long Stripe honors your own idempotency keys
- Retry after a slow response. Your handler does the work but takes too long to return 2xx. Stripe times out, assumes failure, and retries — so the work runs a second time even though the first one actually succeeded.
- Genuine redelivery. Stripe's infrastructure occasionally sends the same event more than once on its own. You're promised at least one delivery, never exactly one.
The fix: make the handler idempotent
Idempotent means running it twice has the same effect as running it once. The standard way to get there uses the one thing that stays constant across every redelivery: the event id.
Every event carries a stable evt_ id. Before doing any work, record that id in a processed_events table with a unique constraint. If the insert succeeds, it's the first time you've seen this event — do the work. If it fails on the unique constraint, you've already handled it — acknowledge with 2xx and stop.
Insert first, process second. That ordering also handles the race where two copies arrive at the same instant: the database lets exactly one insert win, and the loser bails out cleanly.
Make my Stripe webhook handler idempotent so a duplicate delivery can't double-charge or double-provision. I'm using <FRAMEWORK> and <DATABASE>. Requirements: - Stripe can deliver the same event more than once (at-least-once delivery), and retries after a failed 2xx also re-send events. My handler must be safe to run twice with the same event. - Use the Stripe event id (evt_...) as an idempotency key: before doing any work, try to record the event id in a "processed_events" table with a unique constraint. If it's already there, acknowledge with 2xx and do nothing. - Make the record-and-process step safe against races (two deliveries arriving at once) — e.g. insert-first with the unique constraint, or a transaction, so only one wins. - For any outbound side effects I create myself (like creating a charge or sending an email), show me where to attach my own idempotency key too. - Return 2xx once the event is safely recorded as processed. Give me the full handler plus the SQL for the processed_events table.
Your own idempotency keys, too
There's a second layer worth knowing. When your code calls the Stripe API to create a charge or subscription, you can attach an Idempotency-Key header. Stripe honors it for 24 hours: send the same key twice and the second call returns the original result instead of creating a duplicate. Use a key derived from the event you're processing, and even a duplicated handler run can't create two charges.
Stripe's idempotent requests docs cover the header in detail.
The two failure modes are opposites — watch for both
Duplicate events double things up; the other half of the problem is events that never arrive at all, leaving a paid customer un-provisioned. That's the silent-failure side of the same system. Both are invisible from your app until money or trust is already lost.
Tell Me When Down watches the endpoint your billing depends on, so a webhook that starts erroring — and triggering the retries that cause duplicates in the first place — reaches you before your customers' card statements do.
Charge once, provision once — and know when the endpoint's in trouble.
Join Tell Me When Down free and we'll watch your Stripe webhook endpoint around the clock. When it starts erroring — the exact condition that triggers retries and duplicates — you get an email in minutes, not a double-charge complaint.
spot something wrong or out of date? [email protected] — we'll fix it