Back to Blog
Tutorial 9 min read

Fix: Stripe Webhook 'No Signatures Found Matching the Expected Signature'

Why Stripe webhook signature verification fails in Next.js and how to fix it: raw body, the right whsec_ secret, Vercel env vars and middleware.

By the Amex Technology Team

Fix: Stripe Webhook 'No Signatures Found Matching the Expected Signature' — guide by Amex Technology

No time to debug this Vercel issue? Tell us what's broken and our developers will get it working, usually within 24 hours.

Contact Us

"No signatures found matching the expected signature for payload" means Stripe's library computed a signature from the body and secret you gave it, and that signature did not match the one in the Stripe-Signature header. In almost every case, either your code parsed the request body before verifying it, or you are using the wrong webhook signing secret. In a Next.js App Router route, the fix is to pass await req.text() (never req.json()) to stripe.webhooks.constructEvent, along with the whsec_ secret that belongs to the exact endpoint or stripe listen session sending the event.

The full error usually looks like this in your logs:

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

The rest of this guide covers why it happens, the working code for the App Router and Pages Router, and a checklist for the less obvious causes that still trip people up after deploying to Vercel.

Why Stripe rejects the signature

Stripe signs every webhook it sends. It takes a timestamp and the exact bytes of the JSON body, runs them through HMAC-SHA256 using your endpoint's signing secret, and puts the result in the Stripe-Signature header in the form t=...,v1=....

When you call constructEvent(body, signature, secret), the library repeats that calculation on your side. Three inputs have to be exactly right:

  1. The body must be the raw string Stripe sent, byte for byte. If anything parsed it into an object and turned it back into a string, the key order, spacing or escaping can change, and the hash will not match.
  2. The signature must be the full Stripe-Signature header value.
  3. The secret must be the signing secret of the endpoint that sent this particular event.

If any one of them is off, you get the same error. Stripe's own docs say the wrong secret is the most common cause. In Next.js projects, a parsed body is a close second.

Fix 1: Read the raw body in the App Router

In a route handler (app/api/webhooks/stripe/route.ts), the Request object gives you the raw body through req.text(). Use that and nothing else.

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
  const body = await req.text(); // raw body, not req.json()
  const signature = req.headers.get("stripe-signature");
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!signature || !secret) {
    return NextResponse.json({ error: "Missing signature or secret" }, { status: 400 });
  }
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, secret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error("Stripe webhook verification failed:", message);
    return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 });
  }
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      // fulfil the order, keyed on session.id so retries are safe
      break;
    }
    default:
      break;
  }
  return NextResponse.json({ received: true });
}

This matches the pattern in Stripe's official Next.js example in the stripe-node repository.

Common mistakes that cause the error in this file:

  • Calling await req.json() first, then JSON.stringify() on the result. The string looks the same, but it is not the same bytes.
  • Reading the body twice. A request body can only be consumed once, so if something else (a logger, a helper) calls req.json() before your code, req.text() will fail or return an empty string.
  • Calling constructEvent with event.data or another object instead of the string. That gives a different error, "Webhook payload must be provided as a string or a Buffer", but the cause is the same.

You do not need the old export const config = { api: { bodyParser: false } } in the App Router. That setting only applies to the Pages Router, and route handlers never parse the body for you.

Fix 2: Disable the body parser in the Pages Router

In pages/api, Next.js parses JSON bodies automatically, so by the time your handler runs, req.body is already an object. You have to turn that off for the webhook route and read the stream yourself.

// pages/api/webhooks/stripe.ts
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const config = {
  api: { bodyParser: false },
};
async function readRawBody(req: NextApiRequest): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) {
    chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
  }
  return Buffer.concat(chunks);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") {
    res.setHeader("Allow", "POST");
    return res.status(405).end();
  }
  const body = await readRawBody(req);
  const signature = req.headers["stripe-signature"] as string;
  try {
    const event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
    // handle event.type here
    return res.status(200).json({ received: true });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return res.status(400).send(`Webhook Error: ${message}`);
  }
}

The config export has to be in the same file as the webhook handler. Turning off the parser somewhere else does nothing for this route.

Fix 3: Use the right signing secret

If your body handling is correct and you still see the error, check the secret next. Stripe issues a different whsec_ value in each of these places:

  • Stripe CLI (stripe listen): prints a secret in the terminal when it starts, like Your webhook signing secret is whsec_.... This only verifies events forwarded by that CLI session.
  • Dashboard endpoint, test mode: open the endpoint under Developers, Webhooks (Workbench) and click Reveal secret.
  • Dashboard endpoint, live mode: a separate endpoint with its own secret, even if the URL is identical to the test one.

Typical mix-ups:

  1. Using the CLI secret in production, or the dashboard secret while testing locally with stripe listen --forward-to localhost:3000/api/webhooks/stripe.
  2. Using the test mode secret after switching to live keys.
  3. Having two endpoints pointing at the same URL (for example, an old one and a new one). Each sends its own signature, and only one matches your secret.
  4. Rolling the secret in the dashboard and not updating the environment variable.

A quick way to confirm which secret your code is actually using is to log a short fragment of it, then compare it to the dashboard:

console.log("webhook secret starts with", process.env.STRIPE_WEBHOOK_SECRET?.slice(0, 10));

Remove that line after you have checked.

Fix 4: Check the environment variable on Vercel

A lot of "it works locally but fails in production" reports come down to how the secret was stored on Vercel:

  • Wrong environment. Vercel keeps separate values for Production, Preview and Development. If you added STRIPE_WEBHOOK_SECRET only to Preview, production reads undefined or an old value.
  • No redeploy. Changing an environment variable does not affect deployments that are already live. Redeploy after editing it.
  • Hidden whitespace. Piping a value into vercel env add with echo adds a trailing newline. Pasting from some password managers adds a space. Either one breaks the HMAC. Re-enter the value by hand, or trim it in code with process.env.STRIPE_WEBHOOK_SECRET?.trim().
  • Quotes. In a local .env.local file, quotes are fine. In the Vercel dashboard, quotes become part of the value.

If your build itself is failing after adding secrets, see how to fix Vercel builds that fail after adding env variables. The same rules about scopes and redeploys apply to Stripe keys.

Fix 5: Make sure nothing touches the request before your handler

Even with perfect code, something in front of the route can change or block the request:

  • Middleware or proxy. If your middleware.ts (renamed proxy.ts in Next.js 16) runs auth checks, rewrites or locale redirects on every path, exclude the webhook path in the matcher. A redirect also breaks delivery, because Stripe treats any 3xx response as a failure.
  • Domain redirects. If the endpoint URL in Stripe uses example.com but Vercel redirects to www.example.com, the webhook never reaches your code. Use the final URL in the Stripe dashboard. Our guide to Vercel 404 errors after deploy covers how to check what a route actually returns.
  • Deployment Protection. Preview deployments on Vercel are often protected and return 401 to Stripe. Test webhooks against production, a protection bypass, or the Stripe CLI.
  • Proxies and CDNs. Some security tools rewrite or re-encode bodies. If you route traffic through one, make sure it passes the webhook path through unchanged.

Related error: timestamp outside the tolerance zone

If the message says "Timestamp outside the tolerance zone" instead, the signature may be correct but the event is too old. By default the library rejects events signed more than 300 seconds ago. This shows up when you replay an old event by hand, or when the server clock is wrong. Resend the event from the dashboard or with stripe events resend evt_... rather than replaying a saved payload.

Running on the Edge runtime or Cloudflare Workers

constructEvent uses Node's crypto module. If your route runs on the Edge runtime (export const runtime = "edge") or on Cloudflare Workers, use the async version with the Web Crypto provider:
const event = await stripe.webhooks.constructEventAsync(
  body,
  signature,
  secret,
  undefined,
  Stripe.createSubtleCryptoProvider()
);

The raw body rule still applies: body must come from await req.text(). For most Next.js apps on Vercel, the simplest choice is to keep the webhook route on the default Node.js runtime.

Test the fix end to end

  1. Install the Stripe CLI and run stripe login.
  2. Start your dev server, then run stripe listen --forward-to localhost:3000/api/webhooks/stripe.
  3. Copy the whsec_ secret it prints into .env.local as STRIPE_WEBHOOK_SECRET and restart the dev server.
  4. In another terminal, run stripe trigger checkout.session.completed.
  5. The CLI should show [200] next to each event. A [400] means verification is still failing, and your server log will say why.
  6. After deploying, open the production endpoint in the Stripe dashboard and check the event deliveries list. Resend a failed event to confirm it now returns 200.

Once verification passes, make the handler idempotent. Stripe can send the same event more than once, so record the event ID (or the session ID) in your database before fulfilling. If you store orders in Supabase, the patterns in connecting Next.js with Supabase work well for this.

Need a hand with Stripe or your Next.js app?

Amex Technology builds and fixes Next.js, Supabase and Stripe apps for founders and small teams, including payment flows that have to work every time. See what we do on our services page, look at past work in our portfolio, or get in touch if your webhooks or checkout are failing. You can also run your site through our free app checker to spot other problems before your customers do.

Fixed it? Check the rest of your Vercel app for free. Our App Checker scans for exposed API keys, public Supabase service keys, SEO problems and slow loading in about 30 seconds.

Check my app free
Stripe Next.js Webhooks Vercel

Bug Fix Service

Still stuck? We'll fix your Vercel error for you.

Skip the hours of trial and error. Send us the error and access to your project, and a senior developer will get it working, usually within 24 hours. We'll review it for free before any work starts.

  • Your bug or error fixed by a senior developer
  • Most fixes delivered within 24 hours
  • Short write-up of the root cause and what changed
  • Free assessment before any work starts
Typically responds within 4 hours

Ready to build your next digital product?

Tell us what you're building. We'll respond with a clear plan, honest scope estimate, and a timeline — no obligations.

No commitment required
Free consultation
4-hour response
No-commitmentfirst call
4hresponse time
5+ yearsexperience