Your endpoint gets the event, you call stripe.webhooks.constructEvent(body, sig,
secret), and it throws "Webhook signature verification failed" / "No
signatures found matching the expected signature for payload". The math underneath is
simple: Stripe computes HMAC-SHA256(secret, timestamp + "." + raw_body) and puts
it in the Stripe-Signature header as t=…,v1=…. If your check fails,
exactly one of three inputs is wrong on your side: the body bytes, the
secret, or the clock. Here's each, most-likely first.
This is the cause ~80% of the time. Stripe signs the exact bytes it sent. If a
body-parsing middleware runs first, what you pass to constructEvent is a
re-serialization — JSON.stringify(req.body) almost never reproduces the original
bytes (key order, whitespace, unicode escapes), so the HMAC can't match.
express.raw({ type: 'application/json' }) before any global
express.json(), and pass req.body (a Buffer) straight through.const body = await req.text() in the
route handler — never req.json().request.get_data() (bytes), not
request.get_json().request.body.read.Every webhook endpoint has its own whsec_… secret — and the
Stripe CLI's stripe listen prints yet another one that belongs only to that CLI
session. The classic mix-ups: verifying Dashboard-endpoint traffic with the CLI's secret (or
vice versa), test-mode secret against live-mode events, or a stale env var after rotating the
secret. Fix: open the exact endpoint in Dashboard → Developers → Webhooks → Reveal
secret, and compare it character-for-character with what your process actually has at
runtime. Pass the whole string including the whsec_ prefix — the SDK expects it.
The t= timestamp is part of the signed payload, and Stripe's SDKs reject
events older (or newer) than 5 minutes by default to block replay attacks. If
your server clock drifts past that, every verification fails even though the HMAC is right.
Fix: sync with NTP. (You can pass a larger tolerance to constructEvent, but fix
the clock instead.)
Less common, but real: a proxy or gateway that decompresses/recompresses, rewrites charset, or trims the body; a tunnel or middleware that touches bytes in flight. The signature check is doing its job — the bytes genuinely changed.
The fastest way to find out which of these it is: point the same Stripe endpoint at a capture bin and let it verify independently.
$ curl https://hookden.pages.dev/new
Add the https://hookden.pages.dev/h/… URL as a (test-mode) endpoint in Stripe, set the
bin's signature scheme to Stripe, paste the same whsec_… secret,
and trigger an event.
Once it verifies in the bin, replay the capture to your real handler — replays resend the original bytes and headers, so the signature still verifies downstream and you can re-test your fix without triggering new Stripe events. Working on localhost? The CLI relay re-delivers captures byte-identically to a local port.
Related: Inspect Stripe webhooks without the Stripe CLI · Webhooks to localhost without a tunnel
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new