Specify a Stripe webhook handler that survives production
Stripe sends events to your endpoint to tell you when a payment succeeded, a subscription renewed, or an invoice failed. Getting that handler right means three things that are easy to forget: verifying the signature, deduplicating retried events, and returning the correct status code. This builder turns your chosen events into a precise specification covering all three.
The processing order — why each step must come first
The order of operations in a webhook handler is not arbitrary. Each step is a gate that prevents the next one from running on bad input.
1. Read raw body bytes (before any framework JSON parsing)
2. Verify signature: stripe.webhooks.constructEvent(rawBody, sig, secret)
→ 400 on failure, stop
3. Check event.id against idempotency store
→ 200 immediately if already seen (duplicate)
4. Dispatch on event.type, run handler logic
5. Record event.id as processed
6. Return 200
Step 1 is the most commonly broken. Express, Fastify, and other frameworks will JSON-parse the request body by default. Stripe signs the exact raw bytes. If your framework has already re-serialised the JSON before you pass it to constructEvent, the signature check will fail — even with the correct signing secret. Use express.raw({ type: 'application/json' }) or the equivalent middleware before your webhook route.
Step 2 catches impersonation and replays. The Stripe-Signature header contains a timestamp and HMAC. constructEvent verifies the HMAC and rejects signatures older than 300 seconds by default, preventing replay attacks where an old valid request is sent again.
Step 3 catches duplicate delivery. Stripe’s retry logic means the same event can arrive multiple times — after a 5xx response, a timeout, or a network interruption. Store event.id in a database with a unique constraint and check it before acting. A duplicate returns 200 immediately without re-running the handler.
Per-event guidance: key fields to read
For each common event type, these are the payload fields your handler will use:
| Event | Object | Key fields |
|---|---|---|
checkout.session.completed | checkout.Session | id, customer, payment_intent, amount_total, metadata |
payment_intent.succeeded | PaymentIntent | id, amount, currency, customer, metadata |
invoice.payment_succeeded | Invoice | subscription, customer, amount_paid, lines.data |
invoice.payment_failed | Invoice | subscription, customer, attempt_count, next_payment_attempt |
customer.subscription.updated | Subscription | id, status, current_period_end, items.data |
customer.subscription.deleted | Subscription | id, customer, cancel_at_period_end |
Always read from event.data.object, not from re-fetching via the API inside the handler — you already have the object, and a re-fetch inside a high-traffic handler adds latency and can hit rate limits.
Response contract and reliability
The status code is the entire contract with Stripe’s retry engine:
200: Handled, or duplicate. Stripe stops retrying.400: Rejected permanently (bad signature, malformed body). Stripe does not retry.5xxor timeout: Transient error. Stripe retries with exponential backoff for up to three days.
Because events may arrive out of order and more than once, every handler must be idempotent — running it twice on the same event must produce the same final state. Keep the handler fast: acknowledge with 200 immediately and push slow work (sending emails, provisioning resources, calling third-party APIs) onto a background queue.