Your webhook handler fires, provisions the subscription, sends the welcome email, and charges the card. Then it fires again. Same event, same customer, second charge. This is not a Stripe bug; it’s an expected consequence of how webhook delivery works, and it has two distinct causes that need two distinct fixes.
How the same event arrives twice
Stripe retries any delivery that doesn’t receive a 2xx response—automatically, for up to three days in live mode using exponential back-off. If your server times out, crashes after processing but before responding, or returns a 5xx, Stripe queues another attempt. That retry carries the same event.id. From Stripe’s perspective, the first delivery didn’t succeed; from your database’s perspective, the work is already done.
That’s the automatic path. There’s also a manual one. In the Stripe Dashboard, you can click Resend on any event up to 15 days after it was created, or use stripe events resend <event_id> via the CLI for up to 30 days. Critically, a successful manual resend does not cancel Stripe’s automatic retry schedule—both can land.
On top of that, Stripe occasionally generates two distinct Event objects for the same underlying action. These have different event.id values, so an ID log alone won’t catch them.
Layer one: deduplicate on event ID
The straightforward case—same event.id arriving more than once—is handled by keeping a log of processed event IDs and skipping any that appear again. Stripe’s own guidance is exactly that: log the IDs you’ve handled and return 2xx without reprocessing when you see a repeat. A quick lookup against a database column or a Redis key before you do any downstream work is enough.
Return 2xx when you skip. Stripe interprets anything else as a failure and will keep retrying.
Layer two: make downstream POST calls idempotent
The ID log protects you when the same event.id arrives twice. It doesn’t protect you against the two-distinct-Event case, and it doesn’t protect you if your own handler crashes after making an API call but before writing the ID to your log. For those situations, the downstream call itself needs to be safe to repeat.
For any POST request your handler makes to the Stripe API—creating a charge, confirming a payment intent, issuing a credit—include an Idempotency-Key header. Stripe will recognize a repeated key and return the result of the original request instead of executing the operation again. Keys need to be unique enough to identify a single operation within the last 24 hours; a combination of the event ID and the operation name (evt_abc123_charge) works well. Official Stripe client libraries can send these automatically if configured to retry, so check your library’s retry settings before rolling your own.
The 24-hour expiry is worth knowing: an idempotency key dropped after that window won’t protect a very delayed retry. For events approaching the three-day retry window, the ID log is the more durable guard.
The control that doesn’t work
Disabling and re-enabling your webhook endpoint looks like it might reset retry behavior, but if you re-enable before Stripe’s next scheduled attempt, retries resume as normal. It only stops retries if the destination stays disabled when the retry fires. Don’t rely on this as a deduplication strategy.
What this covers
These two controls—ID logging and idempotency keys on POST requests—address the duplicate mechanisms Stripe documents. They don’t guarantee idempotency in third-party systems your handler calls (email providers, CRMs, fulfillment APIs). Those services have their own deduplication requirements, which are outside what Stripe’s API semantics can enforce.
Tags
About Daniel Brooks
SaaS operations and pricing analyst
Daniel covers automation tools, ops databases, and the cost of SaaS sprawl. His background is in finance and operations for subscription businesses, where he modeled renewals, seat growth, implementation work, and procurement tradeoffs.
