Skip to content

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.

Zain Khalid MalikZain Khalid MalikCTO & Co-founder, Innovation InsightPublished
Laptop with a code editor open on a desk in front of a desktop monitor

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:

BehaviourWhat Stripe saysWhat it means for your code
RetriesLive-mode events are retried for up to three days with exponential back-off; sandbox events three times over a few hoursA handler that was down for an hour will receive the backlog later, all at once
DuplicatesAn endpoint might occasionally receive the same event more than onceProcessing must be safe to repeat
OrderingStripe does not guarantee delivery in the order events were generatedinvoice.paid can arrive before invoice.created; never assume sequence
SignatureStripe-Signature header with a timestamp and a v1 HMAC-SHA256 signature; the libraries default to a 5-minute toleranceVerify on the raw request body, never on parsed JSON
ResponseReturn a 2xx quickly, before complex logic; 3xx redirects count as failuresAcknowledge first, process on a queue
Disabled endpointsEndpoints that fail for several days stop receiving events and are disabledAlert on webhook failures well before that
Manual resendFrom the Dashboard up to 15 days after the event, or the CLI up to 30 daysYou 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:

  1. 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.
  2. 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.
  3. 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.

When we took over the Paint Nite booking platform, the whole purchase flow lived in one function of about 1,200 lines with no Stripe idempotency keys, which meant a live double-charge risk on every timeout. Adding keys per order and moving fulfilment to webhook-driven, idempotent workers was one of the first changes in the rebuild. The Paint Nite case study covers the rest.

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

CheckDone when
Signature verified on raw bodyA tampered or replayed request older than 5 minutes is rejected
Fast acknowledgementEndpoint returns 2xx in under a second; processing happens in a worker
DeduplicationUnique constraint on event ID; duplicates are no-ops
Transactional processingBusiness change and processed-event record commit together
Order-safeHandlers re-fetch current state or compare versions
Outbound idempotencyEvery POST to Stripe carries a stored idempotency key
ObservabilityAlerts on backlog age and failure rate; daily reconciliation report
ReplayStored 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

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 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.

LinkedIn
FAQ

Related 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.