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 triggersuccessfully exercises the handler locally.