You wired up/api/stripe/webhook, deployed to Vercel, and Stripe keeps retrying with400responses. Your logs sayWebhook signature verification failedorNo signatures found matching the expected signature for payload. Checkout succeeds, but nothing updates in the database because the handler never gets paststripe.webhooks.constructEvent.
Stripe webhook signature verification in the Next.js App Routerdepends on one easy-to-miss detail: you must pass theexact raw bodyStripe signed not a parsed JSON object, not a re-stringified copy. In Route Handlers that meansawait request.text()(people often search forreq.textwhen migrating from other stacks)beforeany JSON parsing. Get that wrong, or let middleware touch the stream, and verification fails every time.
This guide walks through the correct pattern, the failure modes we see most often in production SaaS apps, and a short debugging checklist so you can get back to handling events.
How Stripe signature verification works
When Stripe POSTs to your endpoint, it sends:
Body— raw JSON bytes of the event.
Stripe-Signatureheader— timestamp and one or more signatures (v1) derived from the body and yourendpoint signing secret(whsec_...).
Your server recomputes the signature with the same secret and compares. The official helper is:
stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);If the raw body bytes do not match what Stripe signed because Next.js parsed JSON, middleware consumed the body, or you used the wrong secretconstructEventthrows. That is intentional: it prevents forged webhook calls.
Rule:Read the bodyonce, verify immediately, then useevent.data.objectfor typed payloads. Do not callrequest.json()first.
The correct App Router webhook handler
Create (or fix)app/api/stripe/webhook/route.ts:
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import Stripe from "stripe";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const signature = (await headers()).get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 },
);
}
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
console.error("STRIPE_WEBHOOK_SECRET is not set");
return NextResponse.json(
{ error: "Webhook not configured" },
{ status: 500 },
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (error) {
console.error("Webhook signature verification failed:", error);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// TODO: switch (event.type) { ... }
console.log("Verified event:", event.type, event.id);
return NextResponse.json({ received: true });
}Notes that matter forNext.js App Routerprojects:
request.text()returns the raw string Stripe signed. This is the App Router equivalent of keeping the raw body intact; do not substituterequest.json()andJSON.stringify()whitespace and key order can differ from Stripe’s payload.headers()in Next.js 15+ is async; await it before readingstripe-signature.export const runtime = "nodejs"keeps the Stripe Node SDK on the supported runtime (see Edge section below).Return200with
{ received: true }onlyafterverification succeeds. Return400for bad signatures so you notice misconfiguration; return500only for missing server config, not for handler bugs you want Stripe to retry (handle business errors after verification).
Once verification works, implement event handlers and database sync—covered in our companion guideHow to Sync Stripe Subscriptions to Your Database in Next.js (Prisma + Webhooks).
Did you callrequest.json()before verifying?
Symptom:Verification fails locally and in production, even when the secret looks correct.
Cause:constructEventneeds the raw POST body. If you do:
const payload = await request.json();
const body = JSON.stringify(payload);the bytes rarely match Stripe’s original payload. Same problem if a wrapper parses JSON globally for all/api/*routes.
Fix:Delete the JSON path for this route. Only:
const body = await request.text();
// verify with body
// then use event.data.object — no manual JSON.parse of body neededIf you need to log the event, logevent.typeandevent.idafter verification, not the pre-verify body.
Is middleware consuming or blocking the webhook?
Symptom:401/redirect to login, empty body, or signature failures only in production.
Cause:Auth middleware often runs on/api/:path*and may callgetToken, read cookies, or in custom setups touch the request. Stripe webhooks areunsigned HTTP calls from Stripe’s servers; they do not send your session cookie. Middleware that requires auth will reject them before your route runs.
Fix:Exclude the webhook path from middleware. Example with Auth.js / NextAuth-style middleware at the project root:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// ... your session check for protected routes
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all paths except static files, images, and Stripe webhook.
*/
"/((?!_next/static|_next/image|favicon.ico|api/stripe/webhook).*)",
],
};Adjust the negative lookahead to match your exact webhook URL. The goal is simple:/api/stripe/webhookmust reach the Route Handler without auth redirects and without any code that readsrequest.bodybefore the handler.
Also confirm you did not mount a globalbodyParseror custom server in front of Next.js that buffers the body differently in dev vs prod.
Are you using the wrongwhsecsecret?
Symptom:Works withstripe listenlocally, fails on Vercel (or the opposite).
Cause:Stripe gives youdifferent signing secretsper endpoint:
Source Secret Used when Stripe CLI (stripe listen --forward-to ...)whsec_...printed in the terminal Local forwarding only Dashboard → Developers → Webhooks → your endpoint Signing secret for that URL Production / preview URL hitting that endpoint Stripe Dashboard “Send test webhook” Uses theendpoint’ssecret Testing deployed URL
Common mistakes:
Leaving the CLI secret in
.envon Vercel while the Dashboard sends tohttps://yourapp.com/api/stripe/webhook.Usingtest modesecret while accidentally triggeringliveevents (or reverse).
Rotating the secret in the Dashboard without updating
STRIPE_WEBHOOK_SECRETin the host env.Copying theAPI key(
sk_test_...) or publishable key instead of the webhook signing secret.
Fix:
Open Stripe Dashboard → Webhooks → select the endpoint that matches your deployed URL.
RevealSigning secret→ update
STRIPE_WEBHOOK_SECRETin Vercel (and redeploy).For local dev, run
stripe listen --forward-to localhost:3000/api/stripe/webhookand putthatCLI secret in.env.localonly.
Never commit secrets. Use separate env values per environment.
For a full local loop (CLI, forwarding, test cards), see the upcoming deep diveTest Stripe Webhooks Locally with Stripe CLI and Next.js(slug:stripe-cli-listen-webhook-localhost-nextjs) in this series.
Are you running the webhook on the Edge runtime?
Symptom:Obscure runtime errors, subtle crypto issues, or SDK incompatibility.
Cause:Route Handlers default to Node in many setups, butexport const runtime = "edge"forces Edge. The Stripe Node library expects Node APIs; verification on Edge is a common footgun.
Fix:Set explicitly:
export const runtime = "nodejs";Redeploy and test again. Keep webhook routes on Node unless you have a vetted Edge-specific approach (most SaaS kits standardize on Node for Stripe).
Did something read the request body twice?
Symptom:Intermittent failures or empty body in the handler.
Cause:The Request body is asingle-use stream. If middleware, logging, or a helper callsrequest.text()orrequest.json()first, the route handler may see an empty body or fail verification.
Fix:Ensure only the webhook route reads the body. Do not wrapPOSTin a shared utility that peeks at the body for “all API routes.” If you need request logging, log method and path in middleware not the POST body.
Could a proxy or platform alter the body?
Symptom:Rare; verification fails only behind one host or CDN.
Cause:Anything that modifies the raw body (compression bugs, WAF rewriting) breaks the signature. Most Next.js + Vercel setups pass the body through unchanged.
Fix:Confirm the webhook URL points directly at your deployment. Avoid putting a custom cache in front ofPOST /api/stripe/webhook. Stripe documents using HTTPS endpoints with valid certificates standard on Vercel.
Pages Router vs App Router (migrating projects)
If you still havepages/api/stripe/webhook.ts, Next.js Pages APIdefaults to parsing the body. Disable that for this route only:
export const config = {
api: {
bodyParser: false,
},
};Then read the raw buffer/string and pass it toconstructEventthe same way. In theApp Router, there is nobodyParserflag you userequest.text()on theRequestobject instead.
Debugging checklist (in order)
Work through this list before changing application logic:
Confirm the route is hit— Temporary log at the top of
POST(remove after). Stripe Dashboard → Webhooks → endpoint →Recent deliveriesshows status codes and response bodies.Check
Stripe-Signatureis present— If missing, you are not receiving a real Stripe request (or something stripped headers).Verify env on the deployment—
STRIPE_WEBHOOK_SECRETset for Preview and Production; redeploy after changes. Log only the first few characters of the secret (whsec_abc...) to confirm the right variable loaded—not the full value.Match CLI vs Dashboard secret— Local: CLI secret. Deployed: Dashboard endpoint secret for that exact URL path.
Test mode consistency— Test events with test keys; live with live keys and live webhook endpoint.
Send a test event— Dashboard → your endpoint →Send test webhook→ pick
checkout.session.completedorcustomer.subscription.updated. If test events verify but real Checkout events fail, compare payloads and ensure the same endpoint URL is registered.Middleware matcher— Temporarily bypass all middleware; if verification succeeds, fix the matcher exclusion.
Single read of body— Search the codebase for
request.json()on the webhook path or shared API middleware.
After verification returns 200, if the database still does not update, that is ahandler logicissue—not signature verification. Use thePrisma subscription sync guidefor event types, metadata, and idempotency.
What to return to Stripe (and what not to)
Situation HTTP status Why Invalid or missing signature400Misconfiguration; fix secret or body handling MissingSTRIPE_WEBHOOK_SECRETon server500Deploy/env issue Verified event, handler success200Stops retries Verified event, transient DB outage500Stripe retries acceptable for true transience Verified event, bug in your code500after fix deploy Prefer fixing forward; use Dashboard replay
Do not return 400 for business-rule failuresaftersuccessful verification if you want automatic retries though for idempotent upserts, 200 is often fine once you recordevent.id(see the sync article).
Production-ready verification without the trial-and-error
Most teams lose days on raw-body handling, middleware matchers, and CLI vs production secrets before they write a single line of subscription sync logic. That is unforced error when you are trying to ship billing.
Skryptix AI SaaS Starter Kitincludes:
A Node Route Handler at
/api/stripe/webhookwith signature verification viarequest.text()Middleware patterns that keep webhooks public and dashboard routes protected
Stripe Checkout, customer portal, and Prisma subscription upserts wired to the same handler
Docs for test mode, env vars, and Vercel deploy
Production-ready webhook verification is included inAI SaaS Starter Kit—Get it here.
FAQ
Why do Stripe webhooks in Next.js requirerequest.text()instead ofrequest.json()?
Stripe signs the exact raw bytes of the POST body.request.json()parses those bytes into an object; re-serializing withJSON.stringifydoes not guarantee the same byte sequence.request.text()preserves the original body sostripe.webhooks.constructEventcan validate theStripe-Signatureheader.
How do I verify Stripe webhooks in the Next.js App Router?
Createapp/api/stripe/webhook/route.ts, read the body withawait request.text(), read thestripe-signatureheader, and callstripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET). Exclude this path from auth middleware and use the Node runtime with the Stripe SDK.
Why does signature verification work locally but fail on Vercel?
Usually the signing secret mismatch: local development uses the secret fromstripe listen, while Vercel must use the signing secret from the Stripe Dashboard webhook endpoint that points to your production URL. UpdateSTRIPE_WEBHOOK_SECRETin Vercel and redeploy.
Should Stripe webhook routes go through Auth.js middleware?
No. Stripe does not send your session cookies. Exclude/api/stripe/webhookfrom middleware matchers so the Route Handler receives the POST unchanged and unauthenticated.
Can I use Edge runtime for Stripe webhook signature verification?
For most apps, useexport const runtime = "nodejs"with the official Stripe Node SDK. Edge can introduce compatibility issues; Node is the well-supported path forconstructEventin Next.js SaaS apps.
Written by the team behind Skryptix. We build the AI SaaS Starter Kit so founders can focus on product—not auth and billing infrastructure.
