Key takeaways
- Stripe delivers each event at least once, not exactly once, and not necessarily in order.
- Record every event ID and ignore ones you have seen.
- Acknowledge quickly, then fetch the current object and act on that, not on the payload.
- Use idempotency keys for writes, and reconciliation for anything older than a day.
- Verify signatures against the raw request body.
Every few weeks, a team discovers that a customer was charged once and received two welcome emails, two invoices or two licenses. The code looked fine. The cause is almost always the same: the webhook handler assumed Stripe would deliver each event once, in order, exactly when it happened. Stripe never promised that, and no reliable payment system can.
This guide explains what Stripe actually guarantees, the five mistakes that cause duplicate and missing actions, and a handler design that stays correct when events arrive late, twice or out of order.
What Stripe promises, and what it does not
Stripe delivers webhook events at least once. If your endpoint does not return a successful status code, Stripe retries. In live mode it keeps retrying for up to three days with increasing gaps. That is good news for reliability, and it has two consequences you have to design for:
- An event can arrive more than once. A retry can overlap with a slow first attempt, or your server can process the event and fail to answer in time.
- Events are not guaranteed to arrive in the order they happened. An update can reach you before the creation that preceded it.
- Delivery can be late. After an outage on your side, a backlog arrives together, sometimes days later.
Five mistakes that cause duplicates and gaps
1. Acting on the payload instead of the current object
The event contains a snapshot from the moment it was created. By the time you process it, the object may have changed again. If you create a fulfillment from a stale ‘paid’ snapshot after the payment was refunded, you ship something that was refunded. Treat the event as a notification that something changed, and fetch the current object from the API before you act.
2. Not recognizing an event you have already seen
Every event has a unique ID. Store it the moment you accept it, in a table with a uniqueness constraint. If the insert fails because the ID exists, you have processed it, so return success and do nothing. This one habit removes most duplicate-action bugs.
3. Doing slow work before you answer
If your handler sends emails, updates a CRM and writes to a spreadsheet before it returns, a slow third party can push you past Stripe’s timeout. Stripe then retries, and you do the work again. Acknowledge fast. Verify, record the event ID, return a success code, then do the work from a queue.
4. Skipping signature verification
Anyone who finds your endpoint address can post fake events. Verify the Stripe-Signature header against the raw request body using your endpoint’s signing secret. A parsed and re-serialized body will not match, which is the commonest reason verification ‘does not work’ in frameworks that parse JSON automatically.
5. Assuming order
Do not write logic that requires customer.subscription.created to arrive before customer.subscription.updated. Write logic that looks at the subscription’s current state and decides what is needed. If a related object is not there yet, fetch it, or retry later.
A handler design that holds up
- Verify the signature using the raw body.
- Record the event ID in a ledger with a unique constraint. If it already exists, return success.
- Acknowledge with a 2xx response immediately.
- Queue the event for processing.
- Fetch the current state of the object the event refers to.
- Act idempotently: use a deterministic key for each side effect so repeating it is harmless.
- Alert when processing fails after retries, so a person looks at it.
Idempotency keys for the actions you take
When your handler calls Stripe to create a charge, a transfer or a refund, send an idempotency key. If the call is repeated with the same key, Stripe returns the original result instead of doing it twice. Keys can be up to 255 characters, and Stripe may remove saved keys after at least 24 hours, so a key protects you against quick retries, not against a replay next week. For longer windows, your own ledger has to remember.
Use keys that are derived from the thing being done, such as the event ID plus the action name. Random keys defeat the purpose.
Fulfillment, CRM updates and spreadsheet rows need the same protection. Use the event ID or the object ID as the key for each downstream action, so a retry updates the same row instead of adding another.
Limits worth designing around
| Topic | Value to plan for |
|---|---|
| API request rate | About 100 requests per second in live mode, 25 per second in sandboxes, for most endpoints. |
| Webhook retry window | Up to three days in live mode, with growing gaps between attempts. |
| Event history through the API | The last 30 days. Older events must come from your own storage. |
| Idempotency key length | 255 characters maximum; saved keys can be pruned after at least 24 hours. |
Check Stripe’s documentation for the current values before you rely on them. They change, and they differ by endpoint.
Recovering from an outage
If your endpoint was down, Stripe will keep retrying within its window, but you should not rely on that alone. Keep a small reconciliation job that, once a day, lists events from the API for the last day or two, compares their IDs with your ledger and processes any that are missing. Because your handler is idempotent, running it twice is harmless. The same job also catches the rare event your endpoint accepted but failed to process.
A short checklist
- Signature verified against the raw body
- Event ID stored with a unique constraint
- Answer returned before slow work begins
- Current object fetched before acting
- Idempotency key on every write
- Alert when processing fails
- Daily reconciliation against the events list
- Test mode used for every change first
Do all of this and the handler stops being fragile. It also becomes boring, which is what you want in a payment flow.