Lab notes · N°01 · · 3 min read
Fixing Stripe Webhook Signature Verification Failures in Express
express.json() eats the body before constructEvent can verify it: the usual Stripe webhook failure in Express, the raw-body fix, and the rest of the checklist.
- Written by
- Kamol Nazarov
- Published
- Tags
- stripe · express · webhooks
- Follow
- RSS feed
The note
The first time I wired Stripe webhooks into one of the shop's tools, every test event came back 400 with this:
StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
Search "stripe webhook signature verification failed express" and you'll find a hundred possible causes. Nine times out of ten it's exactly one: something parsed req.body as JSON before stripe.webhooks.constructEvent() ever saw the raw bytes � and in an Express app, that something is almost always express.json(). Here's why it happens, the two-line fix, and the much shorter list of suspects when the fix isn't it.
The code that produces the error
// This version fails:
app.post('/webhooks/stripe', express.json(), (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // parsed object, not raw bytes
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
handleEvent(event);
res.sendStatus(200);
});
Why the signature check is so picky
When Stripe delivers a webhook, it computes an HMAC-SHA256 of timestamp + '.' + raw_body using the endpoint's signing secret (whsec_...) and ships the result in the stripe-signature header. To verify, the SDK recomputes the same HMAC on your side and compares the two. Identical bytes in, identical hash out � but if the bytes differ by a single character, the signatures don't match, and the SDK refuses to hand you the event.
The catch: an HTTP request body is a stream, and a stream can be read exactly once. express.json() reads it, parses it, and hands your route a convenient object. The raw bytes are gone by then � the SDK can't have them.
The trap that keeps people stuck for an afternoon: JSON.stringify(req.body) sometimes reproduces the original payload byte-for-byte (V8 preserves key insertion order, and Stripe sends compact JSON), so the old Stack Overflow answer of "just re-stringify it" appears to work � right up until a payload contains a number that round-trips differently (1e21 becomes 1e+21), a string that was escaped differently on the wire, or a proxy that normalizes the body in transit. You can't reconstruct the exact bytes from a parsed object. You have to keep them.
The fix: raw body on the webhook route, JSON everywhere else
Express runs middleware and routes in registration order, so give the webhook its own raw-body parser and register it before the global JSON parser:
import express from 'express';
const app = express();
// 1. Webhook route first, with its own raw parser:
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // now a Buffer of the exact bytes Stripe sent
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
handleEvent(event);
res.sendStatus(200);
}
);
// 2. Everything else still gets normal JSON parsing:
app.use(express.json());
express.raw({ type: 'application/json' }) only fires for JSON content-types, only on that route, and leaves req.body as the untouched Buffer � exactly what constructEvent wants. If the webhook route lives on a router, the same principle applies: router.post('/stripe', express.raw(...), handler).
If you can't reorder routes: capture the raw body during parsing
Some apps have a global express.json() mounted before routes that are hard to move. The parser lets you hook the buffer before it's parsed:
app.use(express.json({
verify: (req, res, buf) => {
// keep a reference to the raw bytes for this route only
if (req.originalUrl.startsWith('/webhooks/stripe')) {
req.rawBody = buf;
}
}
}));
app.post('/webhooks/stripe', (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
handleEvent(event);
res.sendStatus(200);
});
Same effect: the SDK gets the original bytes, and the rest of the app never notices.
When the raw-body fix isn't it
The error message tells you which failure you actually have:
constructEvent throws |
What it means |
|---|---|
No signatures found matching the expected signature for payload |
Bytes or secret are wrong � raw-body problem, or the wrong whsec |
Unable to extract timestamp and signatures from header |
The stripe-signature header never arrived � a proxy stripped or renamed it |
Timestamp outside the tolerance zone |
The event is older than the tolerance window (default 300s) � check the server clock and any long-running queues |
Three checks that catch the remaining cases:
- Wrong signing secret. Every webhook endpoint has its own secret. Test-mode and live-mode endpoints differ, and if you deleted and re-created the endpoint, the new one got a new
whsec_...� your old env var silently stopped being right. Re-copy it from the Dashboard (Developers ? Webhooks ? your endpoint). - A proxy mangling things. nginx, Cloudflare or a WAF in front of the app can rewrite the body or drop unusual headers. Compare what actually arrives with what Stripe sent.
- Testing locally with the wrong secret.
stripe listenprints its own temporarywhsec_...� that's the one your local server needs while testing, not the Dashboard's.
The two-minute loop to confirm the fix
stripe listen --forward-to localhost:3000/webhooks/stripe
# in another terminal:
stripe trigger payment_intent.succeeded
The CLI prints its local secret on startup, forwards the event, and shows the response code your server returned. A 200 means you're done; a 400 plus the table above points at the next suspect.
One sentence to keep: constructEvent hashes the exact bytes Stripe sent � anything that touches the body first changes the math. Give that one route the raw parser and the error stops being mysterious.