Open inspector →
developer guide

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:

  1. JSON.parse(body)
  2. later JSON.stringify(parsed)
  3. 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 with express.raw({ type: "application/json" }) and pass req.body (a Buffer) to stripe.webhooks.constructEvent.
  • Next.js App Router: await request.text(), not request.json().
  • Fastify / Nest / Koa: disable the JSON parser on that path, or keep a raw-body plugin.
  • Proxies that rewrite JSON or change Content-Length will also break v1.
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

  1. Split Stripe-Signature on commas. Read t and every v1.
  2. Build signedPayload = ${t}.${rawBody}``.
  3. HMAC-SHA256 with the whsec_ for this endpoint and this mode (test vs live). Dashboard secrets are not interchangeable.
  4. 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.

Related guides