Guide

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.

Setting up the endpoint from scratch? Stripe webhooks in Next.js, start to finish →

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):

Edge / Workers variant
event = await stripe.webhooks.constructEventAsync(
  body,
  signature,
  endpointSecret,
  undefined,
  Stripe.createSubtleCryptoProvider(),
);

The mistake that causes it

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.

test.ts
// Exercises the route handlers with real signed requests, no Stripe account needed.
process.env.STRIPE_SECRET_KEY = "sk_test_dummy";
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_secret";

import Stripe from "stripe";

const { POST: good } = await import("./route.ts");
const { POST: broken } = await import("./route-broken.ts");

const stripe = new Stripe("sk_test_dummy");
const secret = process.env.STRIPE_WEBHOOK_SECRET!;

const event = {
  id: "evt_test_1",
  object: "event",
  type: "checkout.session.completed",
  data: { object: { id: "cs_test_123", object: "checkout.session", amount_total: 4900 } },
};
// Stripe sends pretty-printed JSON. This is the exact text that gets signed.
const payload = JSON.stringify(event, null, 2);

function request(body: string, header: string | null) {
  const headers: Record<string, string> = { "content-type": "application/json" };
  if (header) headers["stripe-signature"] = header;
  return new Request("http://localhost/api/webhooks/stripe", { method: "POST", headers, body });
}

const errors: string[] = [];
const realError = console.error;
console.error = (...args: unknown[]) => errors.push(args.slice(1).join(" "));

async function run(label: string, handler: (r: Request) => Promise<Response>, body: string, header: string | null) {
  errors.length = 0;
  const res = await handler(request(body, header));
  const reason = errors[0] ? `  <- ${errors[0]}` : "";
  realError(`${String(res.status).padEnd(4)} ${label}${reason}`);
}

const signed = stripe.webhooks.generateTestHeaderString({ payload, secret });

await run("correct: raw body, right secret", good, payload, signed);
await run("broken: req.json() then JSON.stringify", broken, payload, signed);
await run("correct code, wrong secret", good, payload, stripe.webhooks.generateTestHeaderString({ payload, secret: "whsec_other" }));
await run("body changed (trailing newline added)", good, payload + "\n", signed);
await run(
  "signed 10 minutes ago",
  good,
  payload,
  stripe.webhooks.generateTestHeaderString({ payload, secret, timestamp: Math.floor(Date.now() / 1000) - 600 }),
);
await run("no stripe-signature header", good, payload, null);

// The async variant uses Web Crypto, for edge runtimes and Workers.
const event2 = await stripe.webhooks.constructEventAsync(payload, signed, secret, undefined, Stripe.createSubtleCryptoProvider());
realError(`ok   constructEventAsync + SubtleCryptoProvider -> ${event2.type}`);
Terminal
npm install stripe
npx tsx test.ts

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.

CauseMessage you seeHow to confirm
Body parsed and re-serializedNo signatures found matching the expected signatureLog the body length from req.text() and compare with the Stripe Dashboard event
Wrong signing secretNo signatures found matching the expected signatureCompare the whsec_ value with the endpoint you are hitting, and test vs live mode
Body changed by something in betweenNo signatures found matching the expected signatureTest the route directly with the Stripe CLI, bypassing proxies and middleware
Old timestamp (default tolerance is 5 minutes)Timestamp outside the tolerance zoneCheck the server clock, and whether requests are queued or replayed
Header missingYour 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. 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. 2
    Confirm you read raw text. Search your route for req.json(), request.json() and any body-parsing middleware that runs first.
  3. 3
    Take everything else out. Send a real event straight to the route with the CLI and compare:
    Terminal
    stripe listen --forward-to localhost:3000/api/webhooks/stripe
    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. 4
    Check the clock. A server whose time is several minutes off fails the timestamp check.
  5. 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.

Need Stripe done properly?

Checkout, subscriptions, webhooks that survive retries and failed payments, and the billing edge cases around them: see Stripe integration or the wider payments and subscriptions service.

Next steps

Keep reading

Need a hand with this?

Tell me what you are working on. Free scoping call, fixed quote within 48 hours.

Book a call

← All guides