Stripe Webhook Best Practices: Reliability, Security, and Idempotency
Stripe webhook handling with signature checks, durable queues, duplicate detection, lifecycle events, and secret rotation.
Stripe webhooks notify your application about payment and subscription changes. Handlers need signature verification, durable processing, and duplicate handling so retries do not repeat fulfillment or lose account updates.
Stripe Webhook Best Practices for Production Systems
Verify every signature before processing
Stripe signs every webhook payload with your endpoint's secret. If you skip signature verification, a malicious actor can POST fabricated events to your endpoint and trigger fulfillment without ever paying.
const event = stripe.webhooks.constructEvent(
rawBody, // must be the raw, unparsed bytes
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
The most common reason this fails is middleware that parses JSON before verification reaches your handler. In Express, use express.raw({ type: "application/json" }) for the webhook route — not express.json(). In Next.js App Router, call req.text() directly.
Acknowledge immediately, then process asynchronously
Return a successful response promptly after durably accepting the event. Keep slow API calls and fulfillment work in a worker, as Stripe recommends.
The correct pattern:
- Verify signature.
- Write the raw event to a durable queue (database table, SQS, BullMQ, Inngest).
- Return
200 OKto Stripe. - Process the event from the queue in a separate worker.
This separates delivery from processing. The worker still needs its own retry and idempotency logic.
Implement idempotency at the handler level
Stripe can deliver the same event more than once. Your handler must produce the same outcome whether it runs once or ten times. The cheapest implementation is a processed-events table with a unique constraint on stripe_event_id:
CREATE TABLE stripe_events (
stripe_event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);A unique event ID prevents duplicate claims, but an inserted row does not prove processing finished. Commit the event record and local effects atomically, or track recoverable processing states and retry incomplete work. External effects need their own idempotency keys.
Account for delayed and out-of-order events
Webhook payloads can be delayed or delivered out of order. invoice.payment_succeeded can arrive before customer.subscription.created. Instead of updating your database state directly from the payload, treat the event as a trigger and refetch the resource from the Stripe API:
const subscription = await stripe.subscriptions.retrieve(event.data.object.id);
// now update your database from the live object
Fetching current state can help reconcile delayed notifications. Concurrent workers can still race, so protect local updates and distinguish current-state synchronization from actions that must happen once per event.
Handle the critical subscription lifecycle events
At minimum, handle these events for a subscription business:
checkout.session.completed— provision access after a new subscriptioncustomer.subscription.updated— plan change, quantity change, or status changecustomer.subscription.deleted— subscription cancelled; revoke accessinvoice.payment_failed— send dunning email, flag the accountinvoice.payment_succeeded— extend access period for active subscribers
Choose events and access rules for your billing model, including trials, asynchronous payments, and cancellation timing.
Register separate endpoints per environment
Use distinct webhook endpoints for development, staging, and production. Stripe lets you register multiple endpoints per account. Sharing an endpoint across environments leads to production events landing on staging handlers (or vice versa), which corrupts your data and wastes Stripe retry attempts.
Set up monitoring and alerting on webhook failures
Stripe's Dashboard shows delivery attempts and failures under Developers > Webhooks. For production, also emit a metric or log entry for every event your handler receives and every event it fails to process. Alert on:
- Any
400or5xxresponse to Stripe (means your signature verification or handler is broken) - Processing latency exceeding your SLA
- Queue depth growing without being drained
Test with the Stripe CLI before deploying
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
This lets you send real test events to your local handler and verify idempotency, fulfillment logic, and error paths before any code touches production.
A Note on Webhook Secrets Rotation
If the signing secret is exposed, roll it and deploy the replacement promptly. Stripe supports immediate expiration or an overlap of up to 24 hours. For a suspected compromise, choose the response according to your incident plan and continue rejecting unverifiable events. See Stripe’s rotation guidance.