Stripe webhook signature verification failed in Next.js: causes and fix
By Aman Singh · Published · 8 min read
You set up a Stripe webhook, Stripe sends the event, and your Next.js route answers 400 with this message in the logs:
StripeSignatureVerificationError
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
The fix is usually one line, but the same message also appears for two other causes, which is why people lose hours to it. This guide gives the fix, then a way to tell the causes apart. Every handler and error message below was run in October 2026 with the stripe package (version 23.0.0) on Node.js 20, using signed test requests built with Stripe's own helper. It exercises the same code a route handler runs, with standard Request objects, but not inside a running Next.js server.
Stripe signs the exact bytes it sent. Anything that parses and rewrites the body before verification changes those bytes.
What the error means
Every webhook request carries a Stripe-Signature header. It holds a timestamp and a signature, which Stripe computes from the timestamp, the raw request body and your endpoint's signing secret (the value that starts with whsec_). Your server repeats the calculation. If the result differs, verification fails.
So the signature depends on the body byte for byte. Add a space, reorder a key, change a line ending, and it no longer matches. The message asks whether you are "passing the raw request body" because that is by far the most common way to break it.
The fix for the App Router
Put this in app/api/webhooks/stripe/route.ts. Route handlers receive a standard web Request, and req.text() gives you the body exactly as it arrived.
app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing stripe-signature header", { status: 400 });
}
// The raw text, exactly as Stripe sent it. Never call req.json() before
// verifying: the signature covers the original bytes.
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
} catch (err) {
console.error("Webhook verification failed:", (err as Error).message);
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed":
// Grant access, store the customer ID, and so on.
console.log("paid:", event.data.object.id);
break;
default:
// Ignore events you did not ask for. Still answer 200.
break;
}
return new Response("ok", { status: 200 });
}
Read the signature header first and fail fast with 400 if it is missing.
Use req.text(), never req.json(), before verifying. Verification returns the parsed event for you.
Keep the secret in an environment variable and never in code. It is different for each endpoint and for test and live mode.
Answer 200 for events you ignore. Stripe treats anything that is not a 2xx response as a failed delivery and retries it.
On an edge runtime or Cloudflare Workers, the synchronous verifier is not available because it needs Node's crypto. Use constructEventAsync with Stripe's Web Crypto provider instead (the last line of the test output below shows it passing):
Many handlers start by parsing the JSON, because that is what you do with every other request. Then they pass JSON.stringify(parsed) to the verifier, which feels equivalent and is not. Stripe sends its JSON pretty-printed, and re-serializing produces different bytes.
The broken version
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// The common mistake: parse the body, then try to verify a re-serialized copy.
export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature")!;
const parsed = await req.json();
const body = JSON.stringify(parsed);
try {
stripe.webhooks.constructEvent(body, signature, endpointSecret);
} catch (err) {
console.error("Webhook verification failed:", (err as Error).message);
return new Response("Invalid signature", { status: 400 });
}
return new Response("ok", { status: 200 });
}
Test it with signed requests
You can check all of this without a Stripe account. Stripe's library can build a valid signature header for a payload, so a script can send the handler real signed requests and the kinds of broken ones that cause trouble.
Output (the long error text is shortened to its first sentence):
Output
200 correct: raw body, right secret
400 broken: req.json() then JSON.stringify <- No signatures found matching the expected signature for payload
400 correct code, wrong secret <- No signatures found matching the expected signature for payload
400 body changed (trailing newline added) <- No signatures found matching the expected signature for payload
400 signed 10 minutes ago <- Timestamp outside the tolerance zone
400 no stripe-signature header
ok constructEventAsync + SubtleCryptoProvider -> checkout.session.completed
Three causes, one message
Look at lines 2, 3 and 4 of that output. A re-serialized body, a wrong secret and a body with one extra newline all give the same error. The message cannot tell you which one you have. The timestamp failure is the only one with its own text.
Cause
Message you see
How to confirm
Body parsed and re-serialized
No signatures found matching the expected signature
Log the body length from req.text() and compare with the Stripe Dashboard event
Wrong signing secret
No signatures found matching the expected signature
Compare the whsec_ value with the endpoint you are hitting, and test vs live mode
Body changed by something in between
No signatures found matching the expected signature
Test the route directly with the Stripe CLI, bypassing proxies and middleware
Old timestamp (default tolerance is 5 minutes)
Timestamp outside the tolerance zone
Check the server clock, and whether requests are queued or replayed
Header missing
Your own 400 (the library never runs)
Check the request really came from Stripe and was not stripped by a proxy
Checklist when it still fails
1
Confirm the secret. Each webhook endpoint has its own whsec_ secret, and test and live mode have different ones. When you forward events to localhost with the Stripe CLI, the CLI prints its own secret to use locally, which is not the dashboard one.
2
Confirm you read raw text. Search your route for req.json(), request.json() and any body-parsing middleware that runs first.
3
Take everything else out. Send a real event straight to the route with the CLI and compare:Terminal
If that works and production does not, something between Stripe and your route is changing the body: a proxy, a firewall, or a forwarding service that does not keep the exact bytes.
4
Check the clock. A server whose time is several minutes off fails the timestamp check.
5
Pages Router? Turn the body parser off for that route and read the raw buffer; the App Router does not need this.
After verification: handle events safely
Passing verification is the start. A few habits keep webhook handling reliable:
Respond quickly. Verify, record what you need, return 200, and do slow work in a background job. A handler that takes too long looks like a failure to Stripe.
Expect duplicates. Stripe can deliver the same event more than once, so key your processing on event.id and ignore repeats.
Do not rely on order. Events can arrive out of sequence, so fetch the current object from the API when order matters.
Subscribe only to events you handle. Fewer event types means less noise and less code to maintain.
For the full setup, from creating the endpoint and finding the signing secret to choosing events and testing with the Stripe CLI, see Stripe webhooks in Next.js, which includes a tested handler that skips duplicate events.
Common questions
Why does constructEvent fail in Next.js but work in Express?+
Express setups usually give you the raw body through a dedicated parser. In Next.js the usual cause is calling req.json() first. In the App Router, read req.text() instead.
Do I need to disable the body parser in the App Router?+
No. Route handlers receive a web Request and you choose how to read it. Turning the parser off is a Pages Router step.
Is it the same error for a wrong secret and a changed body?+
Yes. Both print 'No signatures found matching the expected signature for payload'. Only an expired timestamp has its own message.
Can I skip verification while testing?+
Do not ship that. Use the Stripe CLI to send real signed events to your local route, so the code you test is the code you deploy.