Skip to content
← Back to the blog
integrationssecurity

Stripe webhook signature verification failed: how to fix it

Why Stripe says no signatures match the expected signature for payload, and how to fix the wrong secret, a parsed body, a missing header or a clock problem.

By Devonauts Team5 min read
Leer en español →
Close-up of HTML code in a code editor on a dark screen

Stripe webhook signature verification fails when one of three inputs is wrong: the signing secret, the raw request body or the Stripe-Signature header. By far the most common cause is using the wrong whsec_ secret, for example the one from the Stripe CLI in production, or a sandbox secret on a live endpoint. The second is a framework that parses the JSON body before your code sees it, so the bytes Stripe signed are no longer the bytes you verify.

This guide shows how to tell which input is wrong, the exact fix for Express, Next.js and Supabase Edge Functions, and what not to do while you're fixing it.

What does the error actually mean?

The message you usually see is:

No signatures found matching the expected signature for payload.

Stripe signs each delivery by computing a signature over a timestamp plus the exact request body, using your endpoint's secret. Your code recomputes the same thing and compares. If any input differs by a single byte, the results don't match. The error doesn't tell you which input is wrong, so you check them one at a time.

Step 1: Are you using the right signing secret?

Stripe's docs name this as the most common cause. Every endpoint has its own secret, and there are more endpoints than people realise.

Where events come fromWhere the secret is
Stripe CLI (stripe listen)Printed in the terminal when the command starts
Sandbox endpoint in WorkbenchEndpoint page, Reveal secret
Live endpoint in WorkbenchSame, but with the Dashboard in live mode

All of them start with whsec_, so they look interchangeable. They aren't. To check:

  1. Log the first and last four characters of the secret your code actually loads (never the whole value).
  2. Compare them with the secret of the endpoint that is sending this event.
  3. Look for invisible problems: a trailing space or newline, quotes copied into the value, or the variable set in one environment (preview) but not another (production).
  4. Redeploy. Many hosts only read environment variables at deploy time.

If the endpoint's secret was rolled recently, the old one may still be valid for a while. Stripe lets you delay expiry for up to 24 hours, and during that window it signs with both. Once the old one expires, servers still using it fail.

Step 2: Is your framework changing the body?

The body must be the exact string Stripe sent. Anything that parses it into an object and turns it back into a string can reorder keys or change whitespace, and the signature no longer matches.

Express

express.json() parses every request it sees. Register the webhook route with a raw body parser, and register it before the global JSON middleware:

app.post('/webhook', express.raw({ type: 'application/json' }), handler);
app.use(express.json()); // other routes

Next.js App Router

Read the body as text, not JSON:

const body = await req.text();
const event = stripe.webhooks.constructEvent(body, signature, secret);

Calling req.json() first and then JSON.stringify on the result is a very common way to break verification. In the Pages Router, disable the built-in body parser for that API route and read the raw buffer instead.

Supabase Edge Functions (Deno)

Two things are specific to Deno. Use the async version with the Web Crypto provider, and read the body with req.text():

const cryptoProvider = Stripe.createSubtleCryptoProvider();
const event = await stripe.webhooks.constructEventAsync(
  await req.text(), signature, secret, undefined, cryptoProvider
);

Also deploy that function with JWT verification turned off. Stripe doesn't send a Supabase token, so with it on, Supabase rejects the request with a 401 before your code runs. That's a different error, but it often gets reported as "the webhook doesn't work". Our guide on Supabase Edge Function errors in Lovable apps covers that side.

Proxies, API gateways and some middleware can also rewrite bodies. If you run behind one, make sure the raw body reaches your function unchanged.

Step 3: Is the signature header arriving intact?

Log the header value (it's safe to log). It should look like t=1700000000,v1=…, possibly with more entries. If it's empty, your code is probably reading the wrong header name or a framework is dropping it. Header names are case-insensitive, but some helpers that turn headers into a plain object aren't.

Step 4: Is the timestamp too old?

Stripe's libraries reject events whose signed timestamp is more than 5 minutes from your server's current time. That catches replay attacks, but it also catches servers with a wrong clock, and handlers that queue the raw body and verify it much later. Keep your server clock synced with NTP and verify as soon as the request arrives. Don't set the tolerance to 0: Stripe's docs note that this disables the recency check entirely.

Each retry gets a fresh timestamp and signature, so resending an old event from the Dashboard isn't affected.

How do you confirm it's fixed?

  1. Open the endpoint in Workbench and select the Event deliveries tab. Failed deliveries show the HTTP status your server returned.
  2. Resend one failed event and watch for a 2xx.
  3. Locally, run stripe listen --forward-to localhost:3000/api/webhook and stripe trigger checkout.session.completed, using the secret the CLI printed.

Then process the events you missed while it was broken. In live mode Stripe retries failed deliveries for up to three days, so recent ones may recover on their own. For older ones, list events through the API and reprocess them. If paying customers were left without access, our guide on customers who paid but have no access walks through repairing their accounts.

What should you never do while fixing this?

  • Don't remove signature verification "for now". Without it, anyone who finds your webhook URL can post a fake checkout.session.completed and give themselves a paid plan.
  • Don't paste the secret into frontend code or a public repo to test faster.
  • Don't trust the event body alone for anything important. After verifying, fetching the subscription or session from Stripe gives you the current state. Our subscriptions guide uses this pattern.

Quick answers

It works locally but fails in production. Why? Locally you use the CLI's secret; production needs the secret of the Dashboard endpoint for that environment.

Sandbox events verify but live events don't. The live endpoint has its own secret. Put the live whsec_ in your production environment and redeploy.

Could Stripe be sending a bad signature? That's very unlikely. In practice the cause is almost always the secret, the body or the header on your side.

Should I also allowlist Stripe's IP addresses? Stripe publishes them and suggests it as an extra layer, but signature verification is the check that matters.

Still seeing signature errors?

If you've checked all four inputs and deliveries still fail, it's usually something between Stripe and your code, such as a proxy or a platform setting. Our Stripe payments service handles exactly this kind of fix, and you can run the free app health check or tell us about the error. Stripe's own signature troubleshooting page is also worth bookmarking.

Still stuck with your app?

Run the free health check on your app's URL, or get the $299 App Rescue Audit: a senior engineer reviews the code and sends you a written report in 2 business days.