Home

Fix this error · Payments and AI APIs

Fix: No signatures found matching the expected signature for payload

Fix StripeSignatureVerificationError: raw body with req.text() or express.raw(), the right whsec_ secret (CLI vs Dashboard, test vs live), and logging it.

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.

FactDetail
ErrorNo signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?
Error typeStripeSignatureVerificationError
Where you see itYour server logs, and failed deliveries for the endpoint in the Stripe Dashboard
Usual causeThe wrong whsec_ secret (CLI vs Dashboard, test vs live), or a body that was parsed as JSON before verification
Usual fixVerify against the raw body with the secret of the endpoint that sent the event
BugTape catches itWith 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.

// 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.

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:

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:

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:

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

All guides: Fix an error.

Start free. Free plan · No credit card · Set up in minutes. Or try the live demo first.