Stripe Webhook Best Practices: Reliability, Security, and Idempotency
Essential Stripe webhook best practices covering signature verification, idempotency, async processing, and retry-safe event handlers for production systems.
Stripe webhooks are the backbone of any payment integration, but they fail silently in ways that cost real money. A duplicate fulfillment, a missed subscription cancellation, or a handler that times out under load — each of these is a production incident waiting to happen. This guide covers the practices that make webhook handling reliable, secure, and auditable.
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
Stripe's delivery timeout is 10 seconds. If your handler does database writes, third-party API calls, or email sends inside the request lifecycle, you will hit the limit under normal load and Stripe will retry — potentially double-processing your fulfillment logic.
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 decouples delivery from processing and makes retries safe.
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()
);Before processing, attempt an insert. If the insert fails with a unique violation, return early — you have already handled this event.
Do not trust the payload as the source of truth
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
This eliminates entire classes of race condition and stale-data bugs.
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
Missing any of these means your database will diverge from Stripe's state over time.
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 your STRIPE_WEBHOOK_SECRET is ever exposed, rotate it immediately in the Stripe Dashboard and deploy the new value. Until the new value is live, disable the endpoint to prevent processing unsigned events.
Building reliable Stripe webhooks requires deliberate infrastructure choices, not just copying the quickstart. If you want a payment backend that handles retries, idempotency, and lifecycle events correctly from day one, Start a build with Clixo.