Stripe Webhook Signature Verification Failed in Next.js (Production Fix + Retry Strategy 2026)
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.
The problem#
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?
Stripe returns HTTP 400 to itself, so you see failures in the Stripe Dashboard → Developers → Webhooks → your endpoint → "Failed events." Customers complete checkout but their subscription never activates because your customer.subscription.created handler never runs.
Symptoms#
- The Stripe Dashboard shows the events were sent but returned 400.
- Local testing with
stripe triggerfrom the CLI also fails. - The error mentions "the raw request body."
- Your route handler works fine if you skip verification (which you absolutely should not ship).
Root cause#
Stripe computes an HMAC-SHA256 of the request body bytes, signs it with your webhook secret, and sends the result in the Stripe-Signature header. Verification re-computes the hash on your end and compares.
The hash depends on the exact bytes. If you parse the JSON first, even formatting differences (key order, whitespace) change the hash. Next.js framework helpers like req.json() or bodyParser always re-serialize, so the bytes you pass to constructEvent are not the bytes Stripe signed.
There are two flavors of this bug:
- You called
req.json()before verifying. This is the most common cause. - Your webhook secret is wrong. Same error message, different fix. Always 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 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. Stripe shows the secret only once when you create the endpoint — if you regenerated it, the old
STRIPE_WEBHOOK_SECRETis dead. - Test mode vs live mode mismatch. Test webhooks have their own secret, distinct from live. Toggle the mode toggle in the dashboard and check both.
- Local CLI secret vs dashboard secret.
stripe listenprints a different secret every time it starts. Locally you want the CLI one (whsec_...), in production you want the dashboard one. They are not interchangeable. - Environment variable not deployed. Set it on Vercel and redeploy — env-var changes don't apply to existing deployments.
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 env
Remove 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.completed
If 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).
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.
- Is there middleware modifying the request? Authentication middleware that reads the body breaks verification — exclude webhook routes.
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. Build the rotation into your deploy process so a leak is a non-event.
- Idempotency keys on the application side. Even with verification correct, Stripe retries on 5xx; your handler must be safe to run twice.
Related reading#
Frequently Asked Questions
Continue Reading
Fix Next.js `revalidatePath` Not Working in Server Actions (6 Production Causes + Cheat Sheet 2026)
Your Server Action mutates data but the page shows stale values until you hard-refresh. `revalidatePath` is one of those APIs that "succeeds" while doing nothing. Here are the six reasons it no-ops, with the exact fix for each — including the one nobody tells you about: `dynamic = 'force-static'`.
10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)
Avoid these critical mistakes when building with Next.js and Supabase. Learn from real-world errors that cost developers hours of debugging and discover proven solutions.
Fix "cookies() should be awaited" Error in Next.js 15 App Router (Complete Migration Fix 2026)
Next.js 15 broke synchronous `cookies().get()`. Every server-side call must now `await cookies()` first. Here's the precise migration — App Router pages, route handlers, Server Actions, and Supabase SSR — plus the codemod that fixes 90% of call sites automatically.
Browse by Topic
Find stories that matter to you.