Handle Stripe Webhooks

Use Stripe webhooks as the source of truth for fulfilment: signature verification, raw body handling, idempotency, and local testing.

This guide covers handling Stripe webhooks correctly on a Node.js backend, and why they, not the app's payment callback, should trigger order fulfilment.

Quick Answer

Mount your webhook route with the raw request body (before any JSON body parser touches it), verify the Stripe signature header with your webhook signing secret, handle payment_intent.succeeded and charge.refunded idempotently, and return a 200 quickly.

1. Why Webhooks, Not the Client Callback

A user can close the app, lose connectivity, or have their device die between a successful charge and your app finding out about it. Stripe's webhook fires independently of the app, directly from Stripe's servers, so it's the only reliable signal that a payment actually succeeded. Fulfil orders from the webhook; treat the app's own success callback as a UX nicety, not a trigger for anything important.

2. Mount the Route Before Your Body Parser

Stripe signs the raw, unparsed request body. If express.json() (or similar) runs before your webhook route and reserializes the body, the signature check fails every time.

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json()); // after the webhook route, not before

3. Verify the Signature

const event = stripe.webhooks.constructEvent(
  req.body, // must be the raw Buffer
  req.headers['stripe-signature'],
  process.env.STRIPE_WEBHOOK_SECRET
);

Never process an event without this check; anyone can otherwise POST a fake "payment succeeded" event to your endpoint.

4. Handle the Events That Matter

At minimum: payment_intent.succeeded (fulfil the order) and charge.refunded (reverse it). Ignore event types you don't act on, but still return a 200 for them so Stripe doesn't keep retrying.

5. Make Handling Idempotent

Stripe retries webhook delivery on timeout or non-2xx responses, so the same event can arrive more than once. Key your fulfilment logic off the Stripe object ID (e.g. the PaymentIntent ID) and skip if you've already processed it, rather than assuming each delivery is unique.

6. Test Locally with the Stripe CLI

stripe listen --forward-to localhost:4000/webhooks/stripe
stripe trigger payment_intent.succeeded

The CLI gives you a local webhook secret; use it in development instead of your production one.

Common Issues

Signature verification always fails the body was parsed as JSON before reaching Stripe's constructEvent; mount the raw body parser on that route specifically, ahead of any global JSON parser.

Orders get fulfilled twice the same event was delivered more than once and handling wasn't idempotent.

Webhook works locally, silent in production the production endpoint isn't registered in the Stripe dashboard, or the production webhook secret differs from the one in your .env (each endpoint has its own secret).

Verification Checklist

  • the webhook route receives the raw body, not JSON-parsed;
  • the signature is verified against the correct secret for that environment;
  • fulfilment is keyed off the event's object ID and safe to process twice;
  • the endpoint is registered in the Stripe dashboard for both test and live modes;
  • stripe trigger successfully exercises the handler locally.

Next Steps