WritingStripe Webhook Best Practices: Reliability, Security, and Idempotency — LinkIT
4 min readstripe, webhooks, payments, backend, reliability

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:

  1. Verify signature.
  2. Write the raw event to a durable queue (database table, SQS, BullMQ, Inngest).
  3. Return 200 OK to Stripe.
  4. 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 subscription
  • customer.subscription.updated — plan change, quantity change, or status change
  • customer.subscription.deleted — subscription cancelled; revoke access
  • invoice.payment_failed — send dunning email, flag the account
  • invoice.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 400 or 5xx response 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.

Discuss Stripe webhook processing with LinkIT.