Stripe Webhook Signature Verification Failed in Next.js
If Stripe webhooks return `Webhook signature verification failed`, your Next.js route is parsing the JSON before Stripe sees it. Here's the exact raw-body pattern for App Router, Pages Router, and Vercel Edge — plus the three secret-mismatch traps that cause the same error.
If your Stripe integration just broke after deploying to Next.js, this is almost certainly the bug. The error message is generic, the cause is specific, and the fix is one line: read the body with await req.text() and hand that exact string to stripe.webhooks.constructEvent. Everything below explains why that line matters and how to tell this bug apart from the three config bugs that produce the identical error.
What "No signatures found matching the expected signature" actually means#
Your webhook handler logs:
Webhook signature verification failed: No signatures found matching the
expected signature for payload. Are you passing the raw request body you
received from Stripe?constructEvent() takes exactly three inputs — the request body string, the Stripe-Signature header, and your endpoint secret — and Stripe's own troubleshooting guide reduces this error to one statement: at least one of those three parameters is wrong. That's the whole debugging tree. In Next.js the body is wrong far more often than the other two, so start there.
How it shows up in practice:
- The Stripe Dashboard (Workbench → Webhooks → your endpoint → Event deliveries) shows the events were sent but your endpoint returned 400.
- Local testing with
stripe triggerfrom the CLI fails the same way. - Customers complete checkout but their subscription never activates, because your
customer.subscription.createdhandler never runs. - The handler "works" if you skip verification — which you must not ship, because an unverified endpoint will happily process forged events.
Why parsing the body first breaks the HMAC#
The Stripe-Signature header looks like this (one line in reality):
Stripe-Signature: t=1492774577,
v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,
v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39Stripe builds a signed_payload string by concatenating the timestamp (t), a literal ., and the raw JSON request body, then computes an HMAC-SHA256 over it using your endpoint secret as the key. v1 is that signature. (v0 is a deliberately fake scheme Stripe attaches for test events — verification libraries ignore everything that isn't v1, precisely to prevent downgrade attacks.)
Because the raw body is part of the hashed message, the comparison depends on the exact bytes. Stripe's docs are blunt about it: "Any manipulation to the raw body of the request causes the verification to fail" — that includes re-serialized key order, added or stripped whitespace, and encoding changes, all of which req.json() and body-parser middleware silently perform. The body must reach constructEvent as the unmodified UTF-8 string Stripe sent.
So there are two flavors of this bug:
- You called
req.json()(or ran the request through parsing middleware) before verifying. The most common cause, and the one specific to Next.js. - Your webhook secret is wrong. Same error message, different fix. Check #1 first because it's a code bug; check #2 second because it's a config bug.
Fix — App Router (Next.js 13+)#
app/api/webhooks/stripe/route.js:
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export async function POST(req) {
// Critical: read as TEXT, not JSON. Stripe signs the raw bytes.
const body = await req.text();
const signature = req.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Now it's safe to use the parsed event
switch (event.type) {
case 'checkout.session.completed':
// handle...
break;
case 'customer.subscription.updated':
// handle...
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}The line that fixes 95% of cases: const body = await req.text(). Do not replace it with req.json() "for convenience" — constructEvent returns the parsed object for you.
Fix — Pages Router (Next.js 12 and below)#
The Pages Router auto-parses JSON. You need to disable it for this route:
// pages/api/webhooks/stripe.js
import Stripe from 'stripe';
import { buffer } from 'micro';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Disable Next.js body parsing — Stripe needs the raw bytes.
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).end();
}
const buf = await buffer(req);
const signature = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(buf, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).json({ error: 'Invalid signature' });
}
// Handle event...
res.json({ received: true });
}micro's buffer() returns the raw bytes. If you don't want the micro dep, you can read the stream manually — but micro is already a Next.js transitive dependency so importing it adds nothing.
Fix — Edge runtime#
export const runtime = 'edge';
export async function POST(req) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
let event;
try {
// constructEventAsync, not constructEvent — Edge has no sync crypto
event = await stripe.webhooks.constructEventAsync(
body,
signature,
webhookSecret
);
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
// ...
return Response.json({ received: true });
}The timestamp tolerance nobody checks: 5 minutes#
The t= timestamp in the header isn't decoration — it's Stripe's replay-attack protection, and it fails verification on its own schedule. Stripe's official libraries enforce a default tolerance of 5 minutes between the header timestamp and your server's current time. Two consequences:
- Clock skew counts against you. If your server's clock drifts more than the tolerance, perfectly valid events get rejected. Stripe's fix is operational, not code: run NTP so your server clock stays synchronized with Stripe's. (On Vercel and other managed platforms this is handled for you; on a self-managed VPS it's on you.)
- Never pass a tolerance of
0thinking it means "strict." Stripe's docs warn that a tolerance of 0 disables the recency check entirely — the opposite of what it looks like.
One detail that saves head-scratching: when Stripe retries a failed delivery, it generates a new timestamp and a new signature for each attempt. You will never fail verification just because a retry arrives hours after the original event was created.
The secret-mismatch trap#
If the code above is already correct, the error is your webhook secret. Common cases:
- You copied the wrong secret from the dashboard. If you rolled (regenerated) it, the old
STRIPE_WEBHOOK_SECRETis dead. Open the endpoint in Workbench → Webhooks → Reveal secret and compare. - Test mode vs live mode mismatch. Stripe generates a different signing secret for the same endpoint URL in test mode and live mode. Both start with
whsec_and look interchangeable. They aren't. - Local CLI secret vs dashboard secret.
stripe listenprints its own signing secret. Stripe's docs call this out explicitly: don't verify CLI-forwarded events with a Dashboard-managed endpoint's secret, or the other way around. Locally you want the CLI one, in production the dashboard one. - Environment variable not deployed. Set it on Vercel and redeploy — env-var changes don't apply to existing deployments.
- You just rolled the secret with a delayed expiration. When you roll an endpoint secret, Stripe lets you keep the old one active for up to 24 hours, and during that window it sends multiple
v1signatures — one per active secret. Verification succeeds if any of them matches, so a failure during this window means your env var holds neither secret.
To verify the secret is loading at runtime:
console.log('Secret prefix:', webhookSecret?.slice(0, 8));
// Should log "whsec_xx" — if undefined or "whsec_te" vs "whsec_li" mismatch, fix envRemove the log before shipping.
Local testing with the Stripe CLI#
# Terminal 1: forward webhooks to local Next.js dev
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Note the printed secret — set it in .env.local for this session
# > Ready! Your webhook signing secret is whsec_abc123...
# Terminal 2: trigger a test event
stripe trigger checkout.session.completedIf verification still fails locally with the CLI secret, the bug is your code (raw-body issue). If it works locally but fails on Vercel, the bug is env config (production secret mismatch).
What happens to the events you 400'd#
The good news: a signature failure doesn't permanently lose events. Stripe's documented retry behavior:
- Live mode: Stripe retries delivery for up to three days with exponential backoff. Fix the bug within that window and the backlog drains itself.
- Test mode / sandbox: only three retries over a few hours — which is why a broken handler "works eventually" in production but seems to swallow events in testing.
- After the retry window: you can still recover. Resend in the Dashboard works for up to 15 days after event creation;
stripe events resend <event_id> --webhook-endpoint=<endpoint_id>from the CLI works for up to 30 days.
Two related delivery rules worth knowing while you're here:
- Redirects count as failures. Stripe treats any
3xxresponse as a failed delivery. If your webhook URL is behind a non-www → www or http → https redirect (a classic Next.js middleware setup), point the Stripe endpoint at the final URL. - Return 2xx before doing real work. Stripe requires a quick successful status code prior to any complex logic that might cause a timeout — a slow Supabase write inside the handler can turn verified events into "Timed out" failures that retry and pile up.
Debug checklist#
- Is
req.text()called beforeconstructEvent? If not, fix code first. - Does the secret start with
whsec_? If it starts withsk_, you used the API key, not the webhook secret. - Is the env var loaded? Log the prefix temporarily.
- Test mode vs live mode? Check both keys.
- Did you redeploy after setting the env var? Vercel doesn't apply env changes retroactively.
- Is the endpoint URL in the Stripe Dashboard correct? A typo means events go nowhere — and a URL that redirects (3xx) counts as a failed delivery.
- Is there middleware modifying the request? Authentication middleware that reads the body breaks verification — exclude webhook routes. (This is the Next.js cousin of the classic Express bug where
app.use(express.json())sits before the webhook route; Stripe documents that ordering mistake explicitly.) - Does the printed signature header look like
t=xxx,v1=yyy? If not, you're extracting the wrong header or a proxy is stripping it.
Prevention#
- Never call
req.json()in a webhook route. Make it a code-review rule. - Lock webhook routes out of any global body-parsing middleware. Use a
matcherthat skips/api/webhooks/*. - Use the Stripe CLI for local dev. Don't ngrok to the production endpoint or copy production secrets.
- Treat webhook secrets as rotated quarterly. Stripe's roll-secret flow keeps the old secret active for up to 24 hours, so rotation can be zero-downtime: roll with delayed expiration, deploy the new secret, let the old one lapse.
- Log processed event IDs. Stripe's docs warn endpoints "might occasionally receive the same event more than once" — a deduplication check on
event.idkeeps a retried delivery from double-writing your database. - Idempotency keys on the application side. Even with verification correct, Stripe retries on 5xx and your handler must be safe to run twice — but the same risk exists before the webhook, when you create the Checkout Session. A double-tap or a Vercel retry on
stripe.checkout.sessions.createwill charge twice unless you pass anidempotencyKey; the step-by-step idempotency key guide for Next.js covers the Supabase checkout wiring.
Related reading#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Next.js Environment Variables Undefined on Vercel? Fix
Environment variables not working on Vercel? Learn the exact configuration needed for Next.js 15 deployment with zero errors.
Fix 'Hydration failed' in Next.js: 8 Root Causes (2026)
Hydration mismatch errors breaking your Next.js app? Learn the root causes and 8 proven fixes to eliminate these errors permanently.
Next.js Turbopack Stuck on Compiling? 5 Fixes (2026)
Turbopack stuck on compiling in Next.js 15/16? Learn the exact causes and 5 proven fixes, plus how to disable Turbopack safely with next build --webpack when production builds crash.
Browse by Topic
Find stories that matter to you.
