# Fix: No signatures found matching the expected signature for payload

`stripe.webhooks.constructEvent()` computed a signature from the body and secret you gave it, and it did not match the `Stripe-Signature` header. Stripe names the two usual causes: the endpoint secret is the wrong one, or the body is not the exact raw bytes Stripe sent. Pass the raw body (`await req.text()` in a Next.js Route Handler, `express.raw()` in Express) and the `whsec_` secret for this exact endpoint.

| Fact | Detail |
|---|---|
| Error | `No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?` |
| Error type | `StripeSignatureVerificationError` |
| Where you see it | Your server logs, and failed deliveries for the endpoint in the Stripe Dashboard |
| Usual cause | The wrong `whsec_` secret (CLI vs Dashboard, test vs live), or a body that was parsed as JSON before verification |
| Usual fix | Verify against the raw body with the secret of the endpoint that sent the event |
| BugTape catches it | With one line of code: call `bugtape.captureException(err)` in the catch that returns 400 |

## What the error means

Stripe signs each webhook with the endpoint's signing secret. The `Stripe-Signature` header looks like `t=…,v1=…`. stripe-node rebuilds the signed string from the timestamp and the body you pass in, computes an HMAC with your secret, and compares it with each `v1` value. When none match, it throws `StripeSignatureVerificationError` with this message. The full message adds a line about third-party tools that forward webhooks, which must keep the exact body, including JSON formatting and newlines.

stripe-node has related messages for other failures:

- `No stripe-signature header value was provided.` The header did not reach your code.
- `Unable to extract timestamp and signatures from header` The header is not in the `t=…,v1=…` format.
- `Timestamp outside the tolerance zone` The signature is valid but older than the tolerance, 300 seconds by default.
- `Webhook payload must be provided as a string or a Buffer …` You passed a parsed object.
- `Note: The provided signing secret contains whitespace.` is appended when the secret has a space or newline in it.

## Common causes

1. **The wrong endpoint secret.** Stripe calls this the most common cause. The secret printed by `stripe listen` is different from the secret of a Dashboard endpoint. Stripe generates a unique secret for each endpoint, and an endpoint used with both test and live keys has a different secret for each.
2. **The body was parsed before verification.** `express.json()` ran first, or a framework turned the body into an object. Re-serializing it with `JSON.stringify` does not give back the same bytes.
3. **The body was changed on the way.** A proxy, a body-parsing layer or a webhook relay changed whitespace, key order or encoding.
4. **The secret has extra characters.** A trailing newline or quotes copied into the environment variable.
5. **The environment variable is missing in this deployment.** The secret is set locally but not for the environment that runs the handler.

## How to fix it

### Next.js App Router: read the body with `req.text()`

Route Handlers do not parse the body for you. Read it once as text and pass it unchanged.

```ts
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature!, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return Response.json({ message: `Webhook Error: ${(err as Error).message}` }, { status: 400 });
  }

  // handle event.type here
  return Response.json({ received: true });
}
```

Do not call `req.json()` before `req.text()`. The body can be read only once. In the Pages Router, turn off the body parser for the route (`export const config = { api: { bodyParser: false } }`) and read the raw buffer.

### Express: mount `express.raw()` on the webhook route before `express.json()`

Express runs middleware in order. If `express.json()` runs first, the raw body is gone.

```js
import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body, // a Buffer
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET,
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // handle event.type here
  res.json({ received: true });
});

app.use(express.json()); // other routes, after the webhook route
```

### Use the secret of the endpoint that sent the event

For local testing, use the secret that `stripe listen` prints:

```bash
stripe listen --forward-to localhost:3000/api/webhooks/stripe
```

For a deployed endpoint, open it in the Dashboard (Workbench, Webhooks), click **Reveal secret**, and set that value for the matching environment. Test and live events are signed with different secrets, so set the test secret where you receive test events and the live secret where you receive live ones. Both start with `whsec_`.

Print what your code actually uses, without logging the whole secret:

```ts
const s = process.env.STRIPE_WEBHOOK_SECRET ?? '';
console.log('whsec length', s.length, 'ends with', JSON.stringify(s.slice(-4)));
```

A length that is one longer than expected, or a value ending in `\n`, means a stray newline.

### Use `constructEventAsync` where only Web Crypto is available

On runtimes without Node's `crypto`, such as the Edge runtime, synchronous verification is not possible. stripe-node tells you to use `await stripe.webhooks.constructEventAsync(...)` instead.

### Return 400 for a bad signature

Return a 4xx when verification fails, and do not process the event. Stripe treats a non-2xx response as a failed delivery and retries.

## How to catch it in production with BugTape

A signature failure is handled by your own `catch`, which answers 400. That is the correct answer for Stripe, but it means nothing is thrown out of your handler, so no SDK sees it. The BugTape Node.js SDK's Express handler only reports errors that end in a 5xx, and Next.js `onRequestError` only sees errors that are thrown. Add one line in the catch:

```ts
import * as bugtape from '@bugtape/node';

try {
  event = stripe.webhooks.constructEvent(body, signature!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
  bugtape.captureException(err, { severity: 'high', extra: { route: 'stripe-webhook' } });
  return Response.json({ message: 'Invalid signature' }, { status: 400 });
}
```

Call `bugtape.init({ service: 'api' })` once at startup (in Next.js, in `instrumentation.ts` on the Node.js runtime; the Edge runtime is not supported). The capture key comes from `BUGTAPE_KEY`. In a serverless function, `await bugtape.flush()` before returning makes sure the report is sent before the function is frozen. Errors thrown later, while you handle the event, still reach the Express handler or `onRequestError` as 5xx and are reported without extra code.

Every failed delivery has the same message and the same line, so they group into one issue with a count. A sudden jump right after a deploy usually means the secret or the body handling changed. Requests that are not from Stripe also fail verification, so a probe of your webhook URL adds to the count. Set `release` so that when you mark the issue fixed, a report from a newer release reopens it as a regression. Your coding agent can read the issue over MCP (`npx -y bugtape connect claude`, or `cursor` or `codex`) and propose the change. You review the pull request.

## FAQ

### Why does it work with stripe listen but fail in production?

`stripe listen` prints its own signing secret, and your local environment uses it. The deployed endpoint has a different secret, shown in the Dashboard. Set the Dashboard endpoint's secret in your production environment.

### Can I verify with JSON.stringify(req.body)?

No. The signature covers the exact bytes Stripe sent. A parsed and re-serialized body can differ in whitespace, key order or number formatting, so the signature no longer matches. Verify the raw body first, then parse it.

### What status should my endpoint return when verification fails?

Return a 400. Stripe treats non-2xx responses as failed deliveries and shows them on the endpoint's event deliveries. Return 2xx quickly for events you accept, before slow work.

### Why do I get "Timestamp outside the tolerance zone" instead?

The signature matched but the timestamp is older than the tolerance, 300 seconds by default. This happens when you replay a saved request, or when the server clock is wrong. Stripe signs every delivery attempt again with a new timestamp, so resend from the Dashboard or the CLI instead, and keep the server clock synced with NTP.

## Sources

- [Stripe: Manage webhook endpoints, Resolve signature verification errors](https://docs.stripe.com/events/manage-webhook-endpoints#signature-errors) (most common cause, CLI vs Dashboard secret, Express order, Next.js examples), checked 2 Nov 2026.
- [Stripe: Receive Stripe events in your webhook endpoint](https://docs.stripe.com/webhooks) (unique secret per endpoint, test and live secrets differ, `stripe listen`, 5 minute default tolerance, new signature per retry), checked 2 Nov 2026.
- [stripe-node source: Webhooks.ts](https://github.com/stripe/stripe-node/blob/master/src/Webhooks.ts) (exact messages, 300 second default tolerance, `constructEventAsync`), checked 2 Nov 2026.
- [stripe-node example: Next.js App Router webhook](https://github.com/stripe/stripe-node/blob/master/examples/webhook-signing/nextjs/app/api/webhooks/route.ts), checked 2 Nov 2026.

## Related errors

- [Express: Cannot set headers after they are sent](/guides/express-cannot-set-headers-after-sent/): `Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client`
- [Vercel FUNCTION_INVOCATION_FAILED](/guides/vercel-function-invocation-failed/): `500: INTERNAL_SERVER_ERROR Code: FUNCTION_INVOCATION_FAILED`
- [OpenAI 429 insufficient quota](/guides/openai-429-insufficient-quota/): `429 You exceeded your current quota, please check your plan and billing details.`

All guides: [Fix an error](/guides/).

[Start free](/console/register?utm_source=bugtape_seo&utm_campaign=error-guides). Free plan · No credit card · Set up in minutes. Or [try the live demo](/demo/?from=landing&start=1#capture) first.
