How Stripe webhooks work (and how not to break in production)
Signature verification, durable acceptance, retries and safe processing.
Content reviewed:
Stripe webhooks notify your application about subscriptions, invoices and payments. Delivery can be repeated or out of order, and your processing can fail after acceptance. Design for each case explicitly.
Before you start
A Stripe sandbox and an installed, authenticated Stripe CLI using test credentials only.
An HTTP POST endpoint you own; local examples assume localhost:4242/webhook.
Access to a durable database or queue and a worker for production processing.
Scope
Snapshot-event integration design and local smoke tests. The official starter is a handler foundation, not a production durable inbox. Add persistence, atomic claims, retries and idempotent effects before deployment.
Step 1
What a webhook actually is
Stripe → verify → durable acceptance → retryable worker → idempotent effects. Acknowledge only after persistence.
Stripe sends an HTTP POST describing an event to your configured URL. In Workbench → Webhooks → Create event destination, choose snapshot event payloads when your integration reads event.data.object. Your endpoint verifies and durably stores the event; a worker applies business changes later.
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.
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
Select the events your integration handles, for example customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed and customer.updated. Document each handler and avoid selecting all events without a defined handling policy.
Sandbox only: check the CLI account and test credentials first; never add --live. Start your local server at this route, or change the address to match it. Use the listener's signing secret in your server environment, not a Dashboard endpoint secret. Keep that secret private and leave this terminal running.
Step 4
Persist before acknowledging, then process asynchronously
Verify the signature, persist the event in a durable queue or committed database transaction, then respond promptly with 2xx after durable acceptance. If persistence fails, do not acknowledge success. A worker must atomically claim pending work, apply changes and retry failures. An in-memory task started before returning 200 is not durable acceptance.
Tip
A pending-event table can replace a queue if you implement atomic claims, expiring leases, retry scheduling and error monitoring. Keep received_at separate from processed_at.
Step 5
Handle idempotency
Unique event id prevents duplicate storage, not unfinished processing. Retry the pending record; mark success only after processing.
Use a unique event_id to prevent duplicate inbox rows, but successful insertion is NOT successful processing. On duplicate delivery, reuse the existing pending record rather than discarding unfinished work. Workers atomically claim it and keep business effects idempotent, including external calls with appropriate idempotency keys. Set processed_at only after successful processing; failed work remains retryable. Coordinate database writes and external effects so a crash between them cannot repeat a charge or notification.
Step 6
Don't trust event order
Events can arrive out of order and concurrent workers can race. Retrieve current state from Stripe when synchronising a current-state view, but preserve the immutable event for historical or accounting effects. A latest API response cannot replace what happened at the event's time. Use per-object serialization and version or reconciliation rules appropriate to each handler; arrival order is not causal order.
Watch out
Test reversed delivery order and concurrent processing for the same object, not only duplicate event IDs.
Step 7
Build a replay tool
Build a controlled replay path using durable event records. A redelivery must reuse the pending record and retry unfinished processing, not disappear behind event-ID deduplication. Require authorised access, keep an audit trail and preserve idempotent business effects. Make deliberate reprocessing of completed events a separate, reviewed operation.
Step 8
Monitor delivery and processing separately
Monitor non-2xx responses, queue age, expired claims, processing failures and reconciliation gaps. A 200 only proves acceptance, not completion. Store structured status, attempt count, next retry time and last error; alert on stuck work and provide an authorised repair path. Define retention, payload access and redaction rules for personal data and secrets rather than keeping raw logs indefinitely.
Sandbox failure fixture
stripe trigger invoice.payment_failed
In a second terminal using the same sandbox, trigger this fixture while the listener runs. It creates test objects; it is not a real charge or a replay of an existing event. Check both the HTTP response and durable worker completion. Test duplicates, ordering and crash recovery separately.
Verify signatures, accept durably, process idempotently and distinguish current-state synchronisation from historical effects. Monitor unfinished work and test replay after failure. These controls reduce risk; they do not eliminate every incident.
Connect Stripe to FlowMRR for billing-data synchronisation and recurring-revenue metrics. Check connection status and the last sync rather than treating configuration as proof of processing.
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?+
Use a durable inbox with unique event_id, type, payload, received_at, processed_at, status, claim expiry, attempt count, next retry time and last error. Atomic claims prevent concurrent work; the unique ID prevents duplicate storage, not duplicate business effects. Set processed_at only after success and enforce retention and access controls.
Should I expose webhooks behind a CDN or directly?+
Either can work if the route preserves the raw request body and signature header, accepts requests without interactive challenges and does not cache responses. Test signature verification through the deployed CDN or proxy. There is no universal special Cloudflare setting required for Stripe webhooks.