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