Stripe Webhook Signature Verification in Next.js App Router

Why Stripe Webhook Signature Verification Fails in Next.js (and How to Fix It)
You wired up /api/stripe/webhook, deployed to Vercel, and Stripe keeps retrying with 400 responses. Your logs say Webhook signature verification failed or No signatures found matching the expected signature for payload. Checkout succeeds, but nothing updates in the database because the handler never gets past stripe.webhooks.constructEvent.
Stripe webhook signature verification in the Next.js App Router depends on one easy-to-miss detail: you must pass the exact raw body Stripe signed not a parsed JSON object, not a re-stringified copy. In Route Handlers that means await request.text() (people often search for req.text when migrating from other stacks) before any 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 your endpoint 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 secret constructEvent throws. That is intentional: it prevents forged webhook calls.
Rule: Read the body once, verify immediately, then use event.data.object for typed payloads. Do not call request.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 for Next.js App Router projects:
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).Return 200 with
{ received: true }only after verification succeeds. Return 400 for bad signatures so you notice misconfiguration; return 500 only 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 guide How to Sync Stripe Subscriptions to Your Database in Next.js (Prisma + Webhooks).
Did you call request.json() before verifying?
Symptom: Verification fails locally and in production, even when the secret looks correct.
Cause: constructEvent needs 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 needed
If you need to log the event, log event.type and event.id after 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 call getToken, read cookies, or—in custom setups touch the request. Stripe webhooks are unsigned 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/webhook must reach the Route Handler without auth redirects and without any code that reads request.body before the handler.
Also confirm you did not mount a global bodyParser or custom server in front of Next.js that buffers the body differently in dev vs prod.
Are you using the wrong whsec secret?
Symptom: Works with stripe listen locally, fails on Vercel (or the opposite).
Cause: Stripe gives you different signing secrets per 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 the endpoint’s secret Testing deployed URL
Common mistakes:
Leaving the CLI secret in
.envon Vercel while the Dashboard sends tohttps://yourapp.com/api/stripe/webhook.Using test mode secret while accidentally triggering live events (or reverse).
Rotating the secret in the Dashboard without updating
STRIPE_WEBHOOK_SECRETin the host env.Copying the API 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.
Reveal Signing secret → update
STRIPE_WEBHOOK_SECRETin Vercel (and redeploy).For local dev, run
stripe listen --forward-to localhost:3000/api/stripe/webhookand put that CLI 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 dive Test 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, but export 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 a single-use stream. If middleware, logging, or a helper calls request.text() or request.json() first, the route handler may see an empty body or fail verification.
Fix: Ensure only the webhook route reads the body. Do not wrap POST in 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 of POST /api/stripe/webhook. Stripe documents using HTTPS endpoints with valid certificates—standard on Vercel.
Pages Router vs App Router (migrating projects)
If you still have pages/api/stripe/webhook.ts, Next.js Pages API defaults 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 to constructEvent the same way. In the App Router, there is no bodyParser flag you use request.text() on the Request object 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 deliveries shows 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 a handler logic issue—not signature verification. Use the Prisma subscription sync guide for event types, metadata, and idempotency.
What to return to Stripe (and what not to)
Situation HTTP status Why Invalid or missing signature 400 Misconfiguration; fix secret or body handling Missing STRIPE_WEBHOOK_SECRET on server 500 Deploy/env issue Verified event, handler success 200 Stops retries Verified event, transient DB outage 500 Stripe retries—acceptable for true transience Verified event, bug in your code 500 after fix deploy Prefer fixing forward; use Dashboard replay
Do not return 400 for business-rule failures after successful verification if you want automatic retries—though for idempotent upserts, 200 is often fine once you record event.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 Kit includes:
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 in AI SaaS Starter Kit — Get it here.
FAQ
Why do Stripe webhooks in Next.js require request.text() instead of request.json()?
Stripe signs the exact raw bytes of the POST body. request.json() parses those bytes into an object; re-serializing with JSON.stringify does not guarantee the same byte sequence. request.text() preserves the original body so stripe.webhooks.constructEvent can validate the Stripe-Signature header.
How do I verify Stripe webhooks in the Next.js App Router?
Create app/api/stripe/webhook/route.ts, read the body with await request.text(), read the stripe-signature header, and call stripe.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 from stripe listen, while Vercel must use the signing secret from the Stripe Dashboard webhook endpoint that points to your production URL. Update STRIPE_WEBHOOK_SECRET in Vercel and redeploy.
Should Stripe webhook routes go through Auth.js middleware?
No. Stripe does not send your session cookies. Exclude /api/stripe/webhook from 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, use export const runtime = "nodejs" with the official Stripe Node SDK. Edge can introduce compatibility issues; Node is the well-supported path for constructEvent in 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.
