Stripe Webhook Signature Verification Failed
Fix Stripe webhook signature verification failed: verify the raw body, parse t and v1, and never JSON.parse before HMAC.
Stripe signature verification failed means the HMAC Stripe sent does not match the HMAC you computed. It is one failure: the bytes you hashed are not the bytes Stripe hashed, or the secret is wrong. Capture the request first, then fix verification. Do not treat this as a generic HTTP catalog.
Open a webhook inspector, point the Stripe Dashboard “Send test webhook” action at that URL, and copy the exact Stripe-Signature plus body. A payload example shows the Event JSON; this page is only the signature error.
What Stripe signs
Stripe-Signature looks like:
Stripe-Signature: t=1716400000,v1=8f3c0a1b9e2d4c6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f
t is a Unix timestamp. v1 is hex HMAC-SHA256. The signed payload is the ASCII timestamp, a period, and the raw request body:
1716400000.{"id":"evt_...","object":"event",...}
The key is the endpoint signing secret (whsec_...), not the secret API key. Multiple v1 values can appear when Stripe rolls secrets; any matching v1 is valid.
The usual cause: you re-serialized the body
Verification fails when the handler does this:
JSON.parse(body)- later
JSON.stringify(parsed) - HMAC that string
Stripe never saw your pretty-printed JSON. Whitespace, key order, and Unicode escaping change the digest. Use the unmodified bytes from the socket.
Framework traps:
- Express:
express.json()consumes the stream. Register the webhook route withexpress.raw({ type: "application/json" })and passreq.body(aBuffer) tostripe.webhooks.constructEvent. - Next.js App Router:
await request.text(), notrequest.json(). - Fastify / Nest / Koa: disable the JSON parser on that path, or keep a raw-body plugin.
- Proxies that rewrite JSON or change
Content-Lengthwill also breakv1.
const sig = request.headers.get("stripe-signature");
const raw = await request.text();
const event = stripe.webhooks.constructEvent(raw, sig, process.env.STRIPE_WEBHOOK_SECRET);
Confirm t, v1, and the secret
- Split
Stripe-Signatureon commas. Readtand everyv1. - Build
signedPayload =${t}.${rawBody}``. - HMAC-SHA256 with the
whsec_for this endpoint and this mode (test vs live). Dashboard secrets are not interchangeable. - Compare hex digests in constant time. Stripe’s library also rejects timestamps older than the default tolerance (300 seconds). Clock skew looks like a signature failure.
Replay the captured body without letting your shell rewrite it:
curl -i -X POST http://127.0.0.1:3000/webhooks/stripe \
-H 'content-type: application/json' \
-H "stripe-signature: $STRIPE_SIGNATURE" \
--data-binary @captured-body.json
If the inspector shows a valid v1 and your local handler still fails, the secret or the raw-body middleware is wrong. If Stripe’s dashboard test fails and your cURL with the same bytes succeeds, the public URL is not reaching that handler.