Webhook signature verification failed (Stripe)

Filed under APIs, auth & CORS

Most likely causeAbout 10 min · 6 steps

Your server is checking the signature against a parsed or re-typed body, or against the wrong endpoint's signing secret.

Verify against the raw request body
Copy the secret from this endpoint's page
Keep test and live fully separate

Fastest route

Give your AI this prompt

Copy it, replace the bracketed part with your error, and send it.

Preview the prompt
I am getting a Stripe error in my webhook endpoint: "Webhook signature verification failed" (the library also says: No signatures found matching the expected signature for payload).

Here is my webhook route code and how I am testing: [paste route code, and say whether you test with the Stripe CLI, the dashboard test button, or live events].

Please: 1) check whether my code verifies the RAW request body or a parsed JSON body, and fix it to use the raw body for the webhook route only, 2) check that the signing secret comes from this endpoint's page in the Stripe dashboard (it starts with whsec_) and remind me that test, live, and CLI secrets are all different, 3) check that the stripe-signature header is passed through unchanged.

Constraints: never suggest disabling signature verification, do not change how my other routes parse JSON, make one change at a time, and tell me which cause you found before you change code.

Fix it yourself

Follow the repair

  1. Copy the signing secret from the endpoint itself. In the Stripe Dashboard, go to Developers, then Webhooks, and open the exact endpoint that is failing. Reveal its signing secret — it starts with whsec_ — and paste that value into your server's environment variables. Do not reuse a secret from another endpoint, a tutorial screenshot, or an older project: every endpoint has its own, and they are not interchangeable.
  2. Verify against the raw body, not parsed JSON. The signature covers the request body byte for byte, so Stripe's constructEvent needs the original payload exactly as it arrived. If your framework already turned the body into a JavaScript object before your code runs, those original bytes are gone. In a Next.js App Router route, read the body as text:
    const rawBody = await req.text();
    const signature = req.headers.get("stripe-signature");
    
    const event = stripe.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  3. Pass the stripe-signature header through untouched. Read the stripe-signature header from the request exactly as it arrived and hand it to the verification call unchanged. Never rebuild it, trim it, or pull it from a parsed copy of the request. Also check that no middleware, auth layer, or logging tool is consuming or rewriting the body before your webhook handler gets it — anything that touches the body first can quietly change it.
  4. Keep test and live in separate worlds. A test-mode endpoint only verifies events sent in test mode, and a live endpoint only verifies live ones. The Stripe CLI adds a third world: when you run stripe listen, it creates a temporary endpoint and prints its own signing secret in your terminal, which matches only CLI-forwarded events. If webhooks fail locally but work live — or the reverse — crossed secrets are the cause almost every time. Give each environment its own variable and check which one the failing server actually loaded.
  5. Never verify against a re-typed body. Taking the parsed object and running JSON.stringify on it does not recreate the original: spacing and key order change, so the bytes differ from what Stripe signed and verification fails even with the right secret. In Express, the equivalent of step 2 is giving this one route the raw parser before any JSON parser is registered:
    // Express: raw body for the webhook route only
    app.post("/webhooks/stripe",
      express.raw({ type: "application/json" }),
      webhookHandler);
  6. Retest with a real event and watch for a 200. Trigger a genuine event — stripe trigger payment_intent.succeeded from the CLI, or the test-webhook button on the endpoint's dashboard page — and watch the response. A 200 with no signature error means the fix held end to end. A 400 sends you back to steps 1 and 2: in practice, it is always the secret or the raw body, never the events you chose to listen to.
Why this happensOptional explanation

When something happens in Stripe — a payment succeeds, a subscription renews — Stripe tells your server by sending a webhook: an HTTP POST with a JSON body describing the event. Because that URL is public, Stripe proves the message is genuine by signing it. The signature is computed from the exact body bytes plus a secret that only you and Stripe know, and your code recomputes it using the endpoint's signing secret. Match, and the event is real. Off by a single byte, and Stripe's library refuses it.

That byte-for-byte strictness is why this error is almost never about your event-handling code. It is about what happened to the body on its way in, or which secret you checked against. Modern frameworks love to parse JSON automatically before your code runs, and a typical project has at least three different signing secrets floating around — test endpoint, live endpoint, and the CLI's temporary one. Cross any of those wires and every webhook fails in exactly the same way.

  • Parsed body: a JSON body parser ran first, so you verified a re-serialized copy instead of the raw bytes Stripe signed.
  • Wrong secret: the code holds the test secret while live events arrive, the live secret while testing, or the CLI's temporary secret in production.
  • Rebuilt body: code that parses the JSON and stringifies it back produces different spacing and key order than the original.
  • Secret rolled: the signing secret was regenerated in the dashboard, and the server still uses the old one.

Quick answers

Why does it work locally with the CLI but fail on my live site?

Because the CLI prints its own temporary signing secret when you run stripe listen, and that secret only matches CLI-forwarded events. Your live endpoint has a different secret on its dashboard page. Each environment needs its own value in its own environment variables.

Can I skip verification while I debug?

No — verification is the only thing proving a "payment succeeded" event really came from Stripe. Without it, anyone who finds your webhook URL can POST a fake payment event. Debug by logging which secret the server loaded and checking the raw body handling, not by disabling the check.

My code never changed, but it suddenly started failing. Why?

Two usual suspects: someone rolled (regenerated) the signing secret in the dashboard, so the copy in your environment variables is stale; or a framework or plugin update added global JSON parsing in front of your webhook route, so the body gets parsed before verification.

One fix, every Friday.

One real error, explained in plain English, with the fix — in your inbox every Friday. No spam, no jargon, unsubscribe anytime.