Guide

Stripe webhook handling that survives production

A Stripe webhook is an HTTP POST that Stripe sends to your endpoint when something happens in your account – a payment succeeds, a subscription renews, a dispute opens. To handle them in production you need five things: signature verification on the raw body, a fast 2xx response, deduplication on event ID, processing that tolerates out-of-order delivery, and a way to replay what you missed.

This guide covers each one, then the other half of the problem: preventing double charges with idempotency keys on the requests you send to Stripe.

What Stripe guarantees, and what it doesn’t

Most webhook bugs come from assuming guarantees Stripe never made. The actual contract:

  • At-least-once delivery. The same event can arrive more than once, for example when your endpoint responded slowly and Stripe retried.
  • No ordering guarantee. You might receive invoice.paid before invoice.created, or a refund event before the success event you expected first.
  • Retries on failure. If your endpoint doesn’t return a 2xx, Stripe retries with exponential backoff – in live mode, for up to three days. In a sandbox it retries three times over a few hours. Stripe has historically also disabled endpoints that keep failing for days and emailed the account, so treat a failing endpoint as urgent.
  • Signed payloads. Every event is signed with your endpoint’s secret so you can prove it came from Stripe.
  • Snapshot payloads follow an API version – the one set on the endpoint, or your account’s default if the endpoint has none. Pin it explicitly and treat upgrades as a migration. Stripe now also offers thin events, which carry only IDs, are unversioned, and are what it recommends for new integrations; this guide describes snapshot events.

Step 1: verify the signature on the raw body

Each request carries a Stripe-Signature header containing a timestamp (t=) and one or more signatures (v1=). The signature is an HMAC-SHA256 of the timestamp, a period, and the raw request body, keyed with your endpoint’s signing secret (the one that starts with whsec_).

Use the official library’s helper – constructEvent in Node, stripe.Webhook.construct_event in Python, and equivalents elsewhere – rather than writing the comparison yourself. It does a constant-time comparison and rejects events whose timestamp is outside a tolerance window (five minutes by default), which limits replay attacks.

The most common failure: your framework has already parsed the JSON, and the re-serialized body no longer matches byte for byte. In Express, mount express.raw on the webhook route instead of express.json. In Django, use request.body. In Rails, request.raw_post. If verification fails in production but works locally, check this first, then check you’re using the secret for the right endpoint and environment.

Step 2: store, acknowledge, then process

Your handler should do as little as possible synchronously:

  1. Verify the signature.
  2. Insert the event into a webhook_events table, keyed on the event ID, with the raw payload and a status of pending.
  3. Return 200.
  4. Process the event from a background queue.

This keeps responses fast, so Stripe doesn’t time out and retry, and it means a bug in your processing code doesn’t lose the event – it sits in the table waiting for a fix and a retry. Doing slow work like sending emails or calling other APIs inside the request is how endpoints end up disabled.

Step 3: process idempotently

Because delivery is at least once, every handler must be safe to run twice. Two layers of protection:

  • Deduplicate on event ID. A unique constraint on the event ID in your events table means the second delivery of the same event is a no-op at insert time.
  • Make the effect idempotent. Deduplication isn’t enough on its own, because a worker can crash halfway through. Write handlers so the outcome is the same whether they run once or three times: “set subscription status to active” rather than “add one month”, upserts keyed on the Stripe object ID, and a unique constraint on any record you create, such as a fulfillment row per PaymentIntent.

Step 4: handle out-of-order events

Don’t treat the event payload as the current truth. It’s a snapshot from when the event was created, and a newer state may already exist.

The reliable pattern is to use the event as a notification, then retrieve the current object from the API (the PaymentIntent, subscription or invoice) and apply that state through your own state machine. If the object already reached a terminal state, a stale event can’t move it backwards. Don’t fall back to comparing the event’s created timestamp: it has one-second resolution, distinct events can share it, and Stripe says not to use it to determine order. If an API call per event is too expensive, batch or cache the retrievals instead.

This is the part of payment code that is easiest to get wrong. It also underpins the wider design in my payment system architecture guide.

Step 5: retries, replay and recovery

  • Your own retries. Failed processing jobs should retry with backoff, then land in a dead-letter state that someone reviews. Don’t rely on Stripe’s redelivery for processing failures; you’ve already returned 200.
  • Catch-up after an outage. The Events API lets you list events from the last 30 days. A reconciliation job that pages through recent events and inserts any you’re missing (the unique constraint makes this safe) recovers from an endpoint that was down or misconfigured.
  • Manual resend. You can resend individual events from the Dashboard or with the Stripe CLI, which is handy for one-off fixes.
  • Reconciliation as the backstop. Even with all of this, compare your records with Stripe’s balance transactions daily. My payment reconciliation guide shows how.

Preventing double charges with Stripe idempotency

Webhooks are inbound. Double charges usually come from the outbound side: your server calls Stripe, the connection drops before the response arrives, and a retry creates a second PaymentIntent or charge.

Stripe idempotency solves this. Send an Idempotency-Key header on every POST request. If Stripe sees the same key again, it returns the stored result of the first request instead of repeating the operation. Reusing a key with different parameters returns an error, and keys may be removed once they’re at least 24 hours old.

How to use it well:

  • Derive the key from something in your database – a UUID on your order or payment-attempt row – not from a fresh random value per HTTP attempt, or retries won’t share it.
  • Persist the key before calling Stripe, so a crash and retry uses the same one.
  • Create one PaymentIntent per order and reuse it on the client if the customer retries, rather than creating a new one each time the page loads.
  • Disable the Pay button after the first click and make your own payment endpoint idempotent, because the browser retries too.
  • Fulfill from webhooks, not from the client redirect. Customers close tabs; webhooks still arrive.

Stripe webhook best practices checklist

  • Separate endpoints and signing secrets per environment.
  • Subscribe only to the events you handle; fewer events means less noise and load.
  • For Checkout, handle checkout.session.completed and also checkout.session.async_payment_succeeded and async_payment_failed for delayed payment methods.
  • If you use Connect, handle connected-account events on an endpoint configured for them, and read the account field on each event.
  • Log the event ID with every downstream action so you can trace effects back to causes.
  • Alert on webhook backlog size and processing failure rate, not individual errors.
  • Test locally with stripe listen forwarding to your dev server and stripe trigger to fire events, and write automated tests that deliver the same event twice and in reverse order.
  • Plan API version upgrades: create a new endpoint on the new version, run both, then cut over.

When to get help

If your current handler does work inline, has no deduplication, or trusts the event payload as current state, it’s worth fixing before it costs you a customer. That kind of review is a typical first sprint in my payment integration services. The longer argument for why this all matters is in Payments Playbook, the book I wrote about payment systems that survive real customers.

Questions

How do I verify a Stripe webhook signature?

Pass the raw request body, the Stripe-Signature header and your endpoint’s signing secret to the Stripe library’s webhook helper, such as constructEvent in Node. It computes an HMAC-SHA256 over the timestamp and body, compares it in constant time, and rejects stale timestamps.

Why is my Stripe webhook signature verification failing?

Usually because the body was parsed and re-serialized before verification, so it no longer matches byte for byte. Use the raw body. Other causes are using the wrong endpoint secret, mixing test and live secrets, or a server clock far enough off to break the timestamp tolerance.

Can Stripe send the same webhook twice?

Yes. Stripe delivers webhooks at least once, so duplicates happen, particularly after slow responses or retries. Store processed event IDs with a unique constraint and make handlers idempotent so a repeated event has no additional effect.

Does Stripe guarantee webhook order?

No. Events can arrive in a different order from the one in which they were created. Treat each event as a prompt to retrieve the current object from the API, and enforce valid state transitions in your own code.

How do idempotency keys prevent double charges?

When you send an Idempotency-Key header with a request, Stripe stores the result. If a retry arrives with the same key, Stripe returns the original response instead of creating a second charge. The key must be stable across retries, so derive it from your own payment record.

Not sure your webhook handler would survive a bad day?

Send me a description of how your integration works today. In a two-week sprint I’ll review it, fix the riskiest gap, and leave tests that prove it.

GMT+2 · Full EU overlap · US East mornings

Related