Stripe Webhooks and Idempotency
Stripe can send the same event more than once. If your handler is not idempotent, you risk double-provisioning, wrong usage, or duplicate emails. Here is how to handle it.
Why webhooks need idempotency
Stripe uses webhooks to notify your backend when something happens: a subscription is created, an invoice is paid, a customer is updated. Your server might receive the same event more than once, for example after a retry following a timeout or a 5xx response. If your handler creates a record, sends an email, or updates usage every time it runs, a duplicate delivery can cause duplicate side effects: two welcome emails, double-counted usage, or conflicting subscription state.
Idempotency means: processing the same event multiple times has the same effect as processing it once. The second time you see evt_abc123, you do not create a second subscription or add usage again.
Use Stripe’s event ID as the idempotency key
Every Stripe webhook event has a unique id (e.g. evt_1ABC...). Before doing any side effects, check whether you have already processed this event ID. Store processed IDs in a table or cache (e.g. processed_stripe_events with event_id and maybe processed_at). When a webhook arrives:
- Parse the event and read
event.id. - If
event.idis inprocessed_stripe_events, return 200 immediately and do nothing else. - Otherwise, run your logic (create subscription, update usage, send email).
- After success, insert
event.idintoprocessed_stripe_events. - Return 200 to Stripe.
Use a unique constraint or “insert if not exists” so that two concurrent requests for the same event cannot both insert. The first wins; the second sees the duplicate and skips.
Ordering and out-of-order events
Webhooks are not guaranteed to arrive in order. You might get “subscription updated” before “subscription created,” or “invoice paid” before “invoice finalised.” Your handler should not assume that a previous event has already been processed. Design your logic so that:
- You can safely process “subscription updated” even if “subscription created” has not been seen yet (e.g. upsert by Stripe subscription ID).
- You do not depend on a strict sequence; instead, derive state from the payload (e.g. subscription status, current period end) and overwrite local state with what Stripe says.
That way, out-of-order or delayed events still leave your system in a consistent state.
Return 200 only after side effects succeed
Stripe retries if you return a non-2xx status code. So return 200 only after you have successfully applied your side effects and recorded the event ID. If you return 200 first and then fail (e.g. DB error), you will not be retried, and your system may be out of sync with Stripe. Prefer: do the work, persist the event ID, then return 200. If something fails before that, return 500 so Stripe retries.
Summary
- Treat Stripe event IDs as idempotency keys: process each event ID at most once.
- Store processed event IDs and short-circuit on replay.
- Design for out-of-order delivery: use upserts and payload-derived state, not strict ordering.
- Return 200 only after side effects and idempotency record are persisted.
These practices keep your billing and subscription state correct even when Stripe retries or sends duplicates.
Have thoughts on this post or questions? Get in touch. More blog posts.