Back to the academy
Engineering11 min read

How Stripe webhooks work (and how not to break in production)

The architecture, the failure modes, and the patterns we wish we'd known on day one.

Webhooks are how Stripe tells your application about events: subscriptions starting, invoices paid, cards failing. They're conceptually simple — Stripe POSTs JSON to your endpoint — but the failure modes (retries, ordering, signature spoofing, idempotency) cause more SaaS production incidents than any other Stripe topic. This is the survival guide.

Step 1

What a webhook actually is

STRIPEENDPOINTQUEUEWORKERDATABASEStripeevent POST/webhooksverify sigreturn 200QueueFIFO bufferWorkerre-fetch fromStripe APIDBHTTPS + signatureack < 30sasync, retryableidempotent writeOn non-2xx → Stripe retries with same event idup to 3 days · exponential backoff · same payload
Stripe → verified endpoint → queue → worker → database.

When something happens in your Stripe account (e.g. a customer's card succeeds), Stripe sends an HTTP POST to a URL you've configured, with a JSON body describing the event. Your server reads the event and updates your database. That's the whole concept. The complexity is in the edge cases.

Step 2

Verify the signature on every request

Stripe signs every webhook with a secret. If you don't verify the signature, anyone on the internet can POST to your endpoint and forge events. Use Stripe's official SDK: stripe.webhooks.constructEvent(rawBody, signature, secret). If this throws, return 400 immediately — don't process unsigned events.

Watch out

Signature verification requires the raw, unparsed request body. Many frameworks parse JSON before middleware runs — disable JSON parsing for the webhook route specifically.

Step 3

Subscribe to the events you actually need

Stripe has hundreds of event types. Subscribe to a focused subset: customer.subscription.created/updated/deleted, invoice.paid, invoice.payment_failed, customer.updated. Avoid 'subscribe to all events' — your endpoint will be hammered with noise and you'll process events you don't understand.

Step 4

Acknowledge fast, process async

Stripe expects a 2xx response within 30 seconds, otherwise it considers the delivery failed and retries. Your handler should: (1) verify signature, (2) push the event onto a queue, (3) return 200 immediately. Do the actual database work in a background worker. This isolates Stripe from your processing latency and prevents retry storms.

Tip

If you don't have a queue, write the raw event to a 'pending_webhook' table and process it from a cron. Same effect.

Step 5

Handle idempotency

DELIVERY #1evt_1ABC123invoice.paidDELIVERY #2 (RETRY)evt_1ABC123same idINSERT INTO events(id, type, payload)ON CONFLICT (id) DO NOTHINGFIRST WRITE✓ row createdhandler runs onceDUPLICATE⤴ skippedno double-chargeThe unique index on event id is your idempotency key.Cheaper than locks, simpler than queues, harder to get wrong.
Same event id arriving twice → unique index makes the second a no-op.

Stripe will deliver the same event multiple times in two cases: (1) your endpoint returned non-2xx and Stripe retries, (2) Stripe's internal redelivery on rare failures. Every event has an immutable `id` field (e.g. evt_1ABC123). Before processing, check if you've already seen that id. The simplest pattern: a unique index on event_id in your processed-events table — INSERT ON CONFLICT DO NOTHING.

Step 6

Don't trust event order

Webhooks can arrive out of order. customer.subscription.updated for the same subscription can arrive before customer.subscription.created if Stripe retries the first one. Always re-fetch the subscription from Stripe's API in your handler — never trust the event payload alone. The event tells you 'something changed', the API gives you current truth.

Watch out

Building business logic on the assumption that events arrive in causal order will eventually break. Plan for it.

Step 7

Build a replay tool

Eventually a bug will skip events. You'll need to replay them. Stripe's dashboard has a 'Resend' button per event, but for bulk replays you'll want a script that reads from your own webhook log table and re-runs the handler. Build it before you need it.

Step 8

Monitor failure rate

Set an alert when your webhook handler returns non-2xx more than 1% of the time over an hour. Stripe will eventually disable an endpoint that fails too consistently. A single bug in a handler can silently destroy your data sync if you're not watching.

Webhooks are deceptively simple to start with and unforgiving to operate. Verify signatures, queue then process, dedupe by event id, re-fetch from the API, and monitor failure rate. Get those five things right and you'll never have a 3am Stripe incident.

Stripe data, without the pipeline

FlowMRR handles webhook ingestion, idempotency, and re-fetching for you. Connect Stripe and the metrics flow.

Use FlowMRR's pipeline

Questions

Do I need to handle every event Stripe sends?+

No. Subscribe only to the events your app cares about. Unsubscribed events aren't delivered, which keeps your endpoint quiet and your code focused.

What's the right database design for webhooks?+

A `webhook_events` table with (id PRIMARY KEY = Stripe event id, type, payload JSONB, received_at, processed_at). Process from this table. The unique constraint on id handles dedupe; the processed_at column lets you replay un-processed events without effort.

Should I expose webhooks behind a CDN or directly?+

Directly, on a separate route from your main API. CDNs can buffer or modify request bodies, breaking signature verification. Many CDNs (CloudFlare in particular) have a documented bypass setting for Stripe webhooks.