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:
- 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.
- The secret is wrong. You're using the
whsec_fromstripe listenin 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:
await req.text(): works- The raw body as a
Buffer: works JSON.stringify(await req.json()):No signatures found matching the expected signature for payload- The right body with a different
whsec_: the sameNo signatures foundmessage - The parsed object itself:
Webhook payload must be provided as a string or a Buffer ... Payload was provided as a parsed JavaScript object instead. - An empty or missing
stripe-signatureheader:No stripe-signature header value was provided. - A signature that is 10 minutes old:
Timestamp outside the tolerance zone
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 readOr 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 foundRe-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:
- Stripe Dashboard → Developers → Webhooks → your endpoint → reveal the signing secret.
- Set it as
STRIPE_WEBHOOK_SECRETin your host's production environment. Use the same mode as your keys: live secret with live keys. - Redeploy so the running build sees the new value.
- Keep the CLI secret in
.env.localonly.
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
- Redirects. Stripe doesn't follow redirects, so a 307/308 (apex to www, or a trailing slash) is a failed delivery. Run
curl -i -X POST https://your-domain/api/stripe/webhook. A healthy endpoint answers 400 (missing signature), not 3xx. - Middleware/proxy. If
proxy.ts(ormiddleware.ts) runs auth on/api/*, Stripe gets a 401. Exclude the webhook from the matcher. The signature is its authentication.
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
- The body is read once with
await req.text(), before anything else touches it - No
bodyParserconfig ormicroin App Router routes - Production uses the Dashboard endpoint's
whsec_, in the right mode - The route runs on Node.js, or uses
constructEventAsyncon Edge - The registered URL answers 400 to an unsigned
curl, not 3xx or 401 - Your proxy/middleware matcher skips
/api/stripe/webhook
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.