← Blog

Stripe "No signatures found matching the expected signature" in Next.js: the fix

By VibeBoot ·

Short on time? The SaaS Starter already has this wired up: $49 one-time, 14-day money-back guarantee.

Your Stripe webhook works with stripe listen on your laptop. You deploy, and the logs fill up with this:

StripeSignatureVerificationError: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?

This was the most common question when we launched our free fixers on Fazier this week. Four different makers asked some version of "does it handle signature errors caused by Next.js body parsing in the App Router?" So here is the full answer, checked against stripe-node 23.0.0 and Next.js 16.4.3 on Oct 26, 2026.

The short version

That error has exactly two causes:

  1. The bytes changed. Stripe signs the exact body it sent. If your code parses the JSON and turns it back into a string, the signature no longer matches.
  2. The secret is wrong. You're using the whsec_ from stripe listen in production, or the test-mode secret with live events.

stripe-node throws the same message for both, so you have to rule them out one at a time. If you'd rather paste your error and get the fix, the free Stripe webhook fixer covers this and 14 other webhook failures.

We tested every variant

We signed a payload with stripe.webhooks.generateTestHeaderString() and passed it to constructEvent in different ways. The payload was pretty-printed JSON, like the bodies Stripe sends. stripe-node 23.0.0 on Node 20 and 22:

A changed body and a wrong secret give the identical error. That's why this one is so confusing.

App Router: you don't need to turn off a body parser

Most "fix" posts tell you to add this:

export const config = { api: { bodyParser: false } };

That's a Pages Router setting for pages/api/*. App Router route handlers don't parse the body for you. You get a standard Request, and the body stays untouched until you read it. So in app/api/stripe/webhook/route.ts that line does nothing. Delete it, along with any micro or buffer() helper you copied over.

What does break it in the App Router is reading the body yourself before you verify:

console.log("webhook", await req.json()); // the body is used up here
const body = await req.text(); // TypeError: Body is unusable: Body has already been read

Or the quieter version, which doesn't throw until Stripe's check fails:

const json = await req.json();
stripe.webhooks.constructEvent(JSON.stringify(json), sig, secret); // No signatures found

Re-stringifying almost never reproduces Stripe's exact bytes (whitespace, escaping, number formatting), so the HMAC no longer matches.

The route that works

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";

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

export async function POST(req: Request) {
  const sig = req.headers.get("stripe-signature");
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!sig || !secret) {
    return Response.json({ error: "Missing webhook secret or signature" }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    // read the body once, as text, and hand it over untouched
    event = stripe.webhooks.constructEvent(await req.text(), sig, secret);
  } catch (err) {
    return Response.json({ error: `Invalid signature: ${(err as Error).message}` }, { status: 400 });
  }

  // only now use the parsed, verified event
  switch (event.type) {
    // ...
  }
  return Response.json({ received: true });
}

If you need logging, log event after verification, not the request.

If the body is untouched, it's the secret

This is the "works locally, fails in prod" case. stripe listen prints its own whsec_, which is only valid for that CLI session. Your production endpoint has a different one:

  1. Stripe Dashboard → Developers → Webhooks → your endpoint → reveal the signing secret.
  2. Set it as STRIPE_WEBHOOK_SECRET in your host's production environment. Use the same mode as your keys: live secret with live keys.
  3. Redeploy so the running build sees the new value.
  4. Keep the CLI secret in .env.local only.

Edge runtime vs Node: keep the webhook on Node

A Fazier commenter also asked whether the webhook should run on the Edge runtime. Our advice: no. Leave the route on the default Node.js runtime.

constructEvent is synchronous. Without Node's crypto, stripe-node falls back to Web Crypto, which is async only. We reproduced it: the sync call with a Web Crypto provider throws

SubtleCryptoProvider cannot be used in a synchronous context.

If you really need Edge or Cloudflare Workers, this works (we tested it):

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

A webhook is one small route that Stripe calls a few times per customer. Edge saves you nothing here, and Node avoids the problem entirely.

Two more things that look like signature problems

Check it yourself in 30 seconds

Run this in your project (node check.mjs) to watch the raw body pass and the re-stringified one fail:

import Stripe from "stripe";
const stripe = new Stripe("sk_test_123");
const secret = "whsec_test";
const payload = JSON.stringify({ id: "evt_1", object: "event" }, null, 2);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });

console.log(stripe.webhooks.constructEvent(payload, header, secret).id); // evt_1
try {
  stripe.webhooks.constructEvent(JSON.stringify(JSON.parse(payload)), header, secret);
} catch (e) {
  console.log(e.message); // No signatures found matching the expected signature...
}

A Cursor prompt that fixes it

My Stripe webhook fails with "No signatures found matching the expected signature".
Open app/api/stripe/webhook/route.ts (or src/app/...). Read the body once with
await req.text() and pass it unmodified to stripe.webhooks.constructEvent with the
stripe-signature header. Remove any req.json(), JSON.stringify, Pages Router
bodyParser config or micro buffer() code before verification. Keep the route on the
Node.js runtime. Don't change the event handling. Then tell me whether
STRIPE_WEBHOOK_SECRET in production is the Dashboard endpoint's secret (not the
stripe listen one) and the same test/live mode as my keys.

Checklist

Once it verifies, the next thing that bites is retries and ordering: see duplicate and out-of-order Stripe events.

The VibeBoot SaaS Starter ships this route already wired: raw-body verification on the Node runtime, a proxy matcher that leaves the webhook alone, and Vitest tests that reject a missing or bad signature. You can click through the live demo before you buy.