Engineering · 10 min read
Stripe Webhooks Done Right: Idempotency, Retries, Ordering and Replay
How to build Stripe webhook handlers that never double-charge or miss an event: signature checks, idempotency keys, deduplication, ordering and replay.

Short answer
Treat every Stripe webhook as possibly late, duplicated or out of order. Verify the Stripe-Signature header on the raw body, store the event ID and return 2xx immediately, then process from a queue. Make the handler idempotent by recording processed event IDs in the same transaction as the change, re-fetch the object instead of trusting the payload's state, and send an idempotency key on every POST you make to Stripe. Stripe retries live-mode events for up to three days.
What Stripe actually guarantees
Many payment bugs in inherited codebases come from assuming webhooks behave like a function call. They do not. Stripe's own documentation, checked on 13 October 2026, is explicit about the following:
| Behaviour | What Stripe says | What it means for your code |
|---|---|---|
| Retries | Live-mode events are retried for up to three days with exponential back-off; sandbox events three times over a few hours | A handler that was down for an hour will receive the backlog later, all at once |
| Duplicates | An endpoint might occasionally receive the same event more than once | Processing must be safe to repeat |
| Ordering | Stripe does not guarantee delivery in the order events were generated | invoice.paid can arrive before invoice.created; never assume sequence |
| Signature | Stripe-Signature header with a timestamp and a v1 HMAC-SHA256 signature; the libraries default to a 5-minute tolerance | Verify on the raw request body, never on parsed JSON |
| Response | Return a 2xx quickly, before complex logic; 3xx redirects count as failures | Acknowledge first, process on a queue |
| Disabled endpoints | Endpoints that fail for several days stop receiving events and are disabled | Alert on webhook failures well before that |
| Manual resend | From the Dashboard up to 15 days after the event, or the CLI up to 30 days | You have a recovery path, if you notice in time |
Step 1: verify, store, acknowledge
The webhook endpoint should do three things and nothing else: verify the signature against the raw body with your endpoint secret, insert the event ID and payload into a webhook_events table (with a unique constraint on the event ID), and return 200. If the insert hits the unique constraint, the event is a duplicate: return 200 and stop. Everything else, such as updating orders, sending emails or provisioning access, happens in a worker that reads from that table or a queue. This keeps response times in milliseconds, which stops Stripe retrying events your code was simply too slow to acknowledge, and it gives you a durable log to replay from.
- Framework trap: Express, NestJS and Next.js route handlers parse JSON by default. Signature checks fail on the re-serialised body, so mount the webhook route with a raw-body parser. On Chat Your History we verified Stripe and PayPal webhooks on the raw body before either provider's events touched the shared subscription tables.
- Never set the tolerance to zero to "fix" failing checks; that disables replay protection. Fix the clock or the body parsing instead.
- Use a separate endpoint secret per environment and per account. Paint Nite ran separate Stripe accounts, keys and webhook secrets for the US and Canada; mixing them is a classic source of signature failures after a deploy.
Step 2: make processing idempotent
Idempotent means running the handler twice has the same effect as running it once. Three patterns, used together:
- Record the work, not just the event. In the same database transaction as the business change (mark the order paid, add the credits), write the event ID to a processed_events table. If the transaction rolls back, the event is retried; if it commits, a duplicate finds the row and does nothing.
- Use natural keys for side effects. Credit a top-up against a unique constraint on (payment_intent_id) rather than incrementing a balance blindly; create one subscription row per Stripe subscription ID and upsert it.
- Make external side effects safe to repeat. Emails and third-party calls cannot be rolled back, so send them from an outbox table written in the same transaction and mark each row sent once delivered.
Step 3: do not trust event order or payload state
Because events can arrive out of order, a handler that applies customer.subscription.updated payloads in arrival order can overwrite a newer state with an older one. Two safer approaches: re-fetch the object from the API when you process the event and write its current state, or compare a version or timestamp on the object before writing. Stripe now recommends thin events for new integrations: they carry only IDs and an event type, so you always fetch current state, and they are not tied to an API version. Snapshot events, which carry the full object, are still useful when a third-party tool needs the payload or you need previous_attributes.
Step 4: send idempotency keys on your own requests
Webhooks are half the problem; the other half is your calls to Stripe. A network timeout on a create-charge or create-transfer request leaves you not knowing whether it succeeded. Send an Idempotency-Key header on every POST, generated once per business operation (for example the order ID plus the attempt type) and stored before the call. Retrying with the same key returns the original result instead of creating a second charge. Stripe's documentation, checked 13 October 2026: keys may be pruned after 24 hours, can be up to 255 characters, should be random such as a V4 UUID and never contain personal data, and reusing a key with different parameters returns an error. In API v1, Stripe saves and replays the first response, including 500 errors.
Step 5: plan for replay and reconciliation
- Keep the raw events. A webhook_events table with the payload, received time, processed time and error lets you replay a day's events after a bug fix with one command.
- Alert on failures. Track the age of the oldest unprocessed event and the error rate per event type; page someone when either crosses a threshold, long before Stripe disables the endpoint.
- Reconcile daily. A scheduled job lists yesterday's charges, refunds and payouts from the API and compares them with your records. It catches the events you never received, which no amount of handler code can.
- Test with the Stripe CLI. stripe listen forwards events to localhost and stripe trigger fires them; run each handler twice and out of order in your tests.
Checklist
| Check | Done when |
|---|---|
| Signature verified on raw body | A tampered or replayed request older than 5 minutes is rejected |
| Fast acknowledgement | Endpoint returns 2xx in under a second; processing happens in a worker |
| Deduplication | Unique constraint on event ID; duplicates are no-ops |
| Transactional processing | Business change and processed-event record commit together |
| Order-safe | Handlers re-fetch current state or compare versions |
| Outbound idempotency | Every POST to Stripe carries a stored idempotency key |
| Observability | Alerts on backlog age and failure rate; daily reconciliation report |
| Replay | Stored events can be reprocessed after a fix |
The same rules apply to any provider that sends webhooks, from PayPal and Shopify to Twilio and HubSpot. If you need payment flows built or an existing integration hardened, see our payment gateway integration and API development and integration services; for marketplaces that pay out to sellers, read Stripe Connect for marketplaces. Senior engineers on our team bill $25 to $41 an hour.
Sources
- Stripe: receive events in your webhook endpoint: https://docs.stripe.com/webhooks (checked October 2026)
- Stripe: how events work, thin and snapshot events: https://docs.stripe.com/events/how-events-work (checked October 2026)
- Stripe API: idempotent requests: https://docs.stripe.com/api/idempotent_requests (checked October 2026)
- Stripe support: why is Stripe trying to reach my webhook endpoints: https://support.stripe.com/questions/why-is-stripe-trying-to-reach-my-webhook-endpoints
Need a number for your project?
Send a short brief and get a written estimate.
A senior engineer replies within one business day. No sales call required.

Zain Khalid Malik
CTO & Co-founder, Innovation Insight
Zain owns architecture, engineering standards and the platform team at Innovation Insight. He sets the bar for code quality, security and the tooling every squad ships with.
LinkedInRelated questions.
How long does Stripe retry a failed webhook?
In live mode Stripe retries for up to three days with exponential back-off; in a sandbox it retries three times over a few hours. You can also resend events from the Dashboard for up to 15 days.
Does Stripe deliver webhooks in order?
No. Stripe does not guarantee delivery order, so handlers should re-fetch the object or compare versions instead of relying on the sequence events arrive in.
How long are Stripe idempotency keys valid?
Stripe may prune keys once they are at least 24 hours old, after which the same key creates a new request. Keys can be up to 255 characters.
Should I process the webhook inside the HTTP request?
No. Verify the signature, store the event and return 2xx, then process from a queue or worker. Slow handlers cause timeouts, which Stripe treats as failures and retries.