
No time to debug this Vercel issue? Tell us what's broken and our developers will get it working, usually within 24 hours.
"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:
- 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.
- The signature must be the full
Stripe-Signatureheader value. - 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, thenJSON.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
constructEventwithevent.dataor 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, likeYour 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:
- Using the CLI secret in production, or the dashboard secret while testing locally with
stripe listen --forward-to localhost:3000/api/webhooks/stripe. - Using the test mode secret after switching to live keys.
- 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.
- 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_SECRETonly to Preview, production readsundefinedor 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 addwithechoadds 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 withprocess.env.STRIPE_WEBHOOK_SECRET?.trim(). - Quotes. In a local
.env.localfile, 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(renamedproxy.tsin Next.js 16) runs auth checks, rewrites or locale redirects on every path, exclude the webhook path in thematcher. A redirect also breaks delivery, because Stripe treats any 3xx response as a failure. - Domain redirects. If the endpoint URL in Stripe uses
example.combut Vercel redirects towww.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
- Install the Stripe CLI and run
stripe login. - Start your dev server, then run
stripe listen --forward-to localhost:3000/api/webhooks/stripe. - Copy the
whsec_secret it prints into.env.localasSTRIPE_WEBHOOK_SECRETand restart the dev server. - In another terminal, run
stripe trigger checkout.session.completed. - The CLI should show
[200]next to each event. A[400]means verification is still failing, and your server log will say why. - 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.
Related Services
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