Your Stripe webhook handler works in Express. You port it to Next.js App Router, deploy to Vercel, and every webhook fails with "No signatures found matching the expected signature for payload."
You're not alone. This is the most reported webhook issue in the Next.js repository — 16 participants over two years, still catching developers off guard.
The problem is that App Router handles request bodies differently from Express and from Pages Router. The fix requires changes in three places: how you read the body, how you configure auth middleware, and how you test the handler. This guide covers all three.
Why App Router breaks webhook signatures
Webhook signature verification works by hashing the raw request body and comparing it to the signature in the header. The raw bytes must match exactly — if anything re-encodes the body between receipt and verification, the hash changes and the signature fails.
In Express, you solve this with express.raw() or bodyParser: false. In Pages Router, you set bodyParser: false in the route config. App Router has neither of these options.
App Router uses the Web Fetch API. The Request object gives you .text(), .json(), .arrayBuffer(), and .blob(). The key rule: call .text() first, verify the signature against that string, then parse to JSON yourself.
// 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(request: Request) {
// 1. Read the raw body as text — do this FIRST
const body = await request.text();
// 2. Verify the signature against the raw text
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
// 3. The event is verified — process it
switch (event.type) {
case "checkout.session.completed":
// Enqueue for background processing
await queue.add("stripe-event", { payload: body });
break;
}
return NextResponse.json({ received: true });
}The critical detail is line order. If you call .json() first, the runtime parses the body, and any subsequent .text() call returns the re-serialized version — which won't match the original bytes.
Never call request.json() before verifying the signature. Once the body is consumed as JSON, you cannot get the original raw text back. The re-serialized output may differ in whitespace, key ordering, or Unicode escaping.
The three mistakes that break signatures on Vercel
The handler above works locally. These three issues cause it to fail in production:
1. Auth middleware intercepts webhook routes
If you use Clerk, NextAuth, or any auth middleware, it runs on every route by default — including your webhook endpoints. Stripe is not an authenticated user. The middleware returns 401 before your handler executes.
Exclude webhook routes in your middleware matcher:
// middleware.ts
import { clerkMiddleware } from "@clerk/nextjs/server";
export default clerkMiddleware();
export const config = {
matcher: [
// Skip webhook routes, static files, and Next.js internals
"/((?!api/webhooks|_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
],
};The negative lookahead (?!api/webhooks|...) ensures requests to /api/webhooks/* bypass the middleware entirely.
This failure is silent on the webhook side. Stripe sees a 401 and retries. Your logs may not show the middleware rejection if you're not logging middleware responses.
2. Edge Runtime re-encodes the body
If your webhook route runs on the Edge Runtime, some Vercel edge infrastructure may re-encode the request body during proxying. Force your webhook route to use the Node.js runtime:
// app/api/webhooks/stripe/route.ts
export const runtime = "nodejs";This isn't always necessary, but it eliminates an entire class of encoding issues. If your signatures fail only in production (not locally), try this first.
3. Using the test-mode secret in production
Stripe uses different webhook signing secrets for test mode and live mode. If your environment variables point to the test-mode secret but your webhook endpoint receives live-mode events, every signature check fails.
Verify you have the correct secret:
# In Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret
# Test mode and live mode have separate secretsThis isn't App Router-specific, but it's the second most common cause after the body-parsing issue.
The complete handler pattern
Here's the full pattern with all three fixes applied, structured for the verify-enqueue-ACK approach from our architecture guide:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
// Force Node.js runtime to avoid edge body re-encoding
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 = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
// Verify
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
// Enqueue
await queue.add("stripe-event", {
eventId: event.id,
type: event.type,
payload: body,
receivedAt: Date.now(),
});
// ACK
return NextResponse.json({ received: true });
}Adapting for other providers
The raw-body-first pattern is the same for every provider. Only the header name and verification function change.
// app/api/webhooks/github/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
const expected = "sha256=" + createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET!)
.update(body)
.digest("hex");
const valid = timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
await queue.add("github-event", {
event: request.headers.get("x-github-event"),
payload: body,
receivedAt: Date.now(),
});
return NextResponse.json({ received: true });
}Notice that Shopify uses base64 encoding for its HMAC, while GitHub uses hex. Stripe handles verification internally through constructEvent, hiding this detail.
Testing webhook routes end-to-end
Unit tests that mock the request object don't catch the real failures — body re-encoding, middleware interference, runtime mismatches. End-to-end tests send actual signed webhooks to your running handler and verify the result.
The webhooks.cc SDK generates properly signed payloads for each provider, so you don't need test-mode credentials from Stripe or GitHub.
Install the SDK:
npm install @webhooks-cc/sdkTest the signature verification
Use buildRequest to generate a signed webhook payload, then send it to your handler with fetch. This lets you check the HTTP response directly.
import { WebhooksCC } from "@webhooks-cc/sdk";
import { describe, it, expect } from "vitest";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const HANDLER_URL = "http://localhost:3000/api/webhooks/stripe";
describe("Stripe webhook route", () => {
it("accepts a valid signature", async () => {
const signed = await client.buildRequest(HANDLER_URL, {
provider: "stripe",
template: "checkout.session.completed",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
const response = await fetch(HANDLER_URL, {
method: signed.method,
headers: signed.headers,
body: signed.body,
});
expect(response.status).toBe(200);
});
it("rejects an invalid signature", async () => {
const signed = await client.buildRequest(HANDLER_URL, {
provider: "stripe",
template: "checkout.session.completed",
secret: "whsec_wrong_secret",
});
const response = await fetch(HANDLER_URL, {
method: signed.method,
headers: signed.headers,
body: signed.body,
});
expect(response.status).toBe(400);
});
});buildRequest generates the full signed request — method, headers with a valid stripe-signature, and a realistic JSON body — without sending it. You control delivery with fetch, so you can inspect the response status directly.
Test with captureDuring
captureDuring creates an ephemeral endpoint, runs your test logic, and captures any requests that arrive. This is useful when your handler forwards events downstream or when you want to test the full webhook pipeline.
import { matchAll, matchHeader, matchMethod, WebhooksCC } from "@webhooks-cc/sdk";
import { captureDuring } from "@webhooks-cc/sdk/testing";
import { describe, it, expect } from "vitest";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
describe("webhook pipeline", () => {
it("processes a checkout event end-to-end", async () => {
const [captured] = await captureDuring(
client,
async (endpoint) => {
// Point your app at the ephemeral endpoint
await registerWebhookUrl(endpoint.url!);
// Trigger a real checkout flow in your test environment
await triggerTestCheckout();
},
{
expiresIn: "5m",
timeout: "30s",
match: matchAll(
matchMethod("POST"),
matchHeader("stripe-signature")
),
}
);
expect(captured).toBeDefined();
expect(captured.method).toBe("POST");
const body = JSON.parse(captured.body!);
expect(body.type).toBe("checkout.session.completed");
});
});Test the full flow with flow()
The flow() builder chains the entire test sequence — create endpoint, send signed webhook, wait for capture, verify signature, clean up — into one call:
import { WebhooksCC } from "@webhooks-cc/sdk";
import { describe, it, expect } from "vitest";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
describe("provider verification", () => {
const providers = [
{ provider: "stripe", template: "invoice.paid", secret: process.env.STRIPE_WEBHOOK_SECRET! },
{ provider: "github", template: "push", secret: process.env.GITHUB_WEBHOOK_SECRET! },
{ provider: "shopify", template: "orders/create", secret: process.env.SHOPIFY_WEBHOOK_SECRET! },
] as const;
for (const { provider, template, secret } of providers) {
it(`verifies ${provider} ${template} signature`, async () => {
const result = await client
.flow()
.createEndpoint({ expiresIn: "5m" })
.sendTemplate({ provider, template, secret })
.waitForCapture({ timeout: "15s" })
.verifySignature({ provider, secret })
.cleanup()
.run();
expect(result.verification?.valid).toBe(true);
});
}
});This test confirms that your handler correctly verifies signatures for each provider — without needing test credentials from any of them.
Running in CI
These tests need your Next.js app running locally. In GitHub Actions, start the dev server before the test step:
# .github/workflows/webhook-tests.yml
name: Webhook Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
env:
WHK_API_KEY: ${{ secrets.WHK_API_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Start Next.js
run: npm run dev &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run webhook tests
run: npx vitest run --reporter=verbose tests/webhooks/The wait-on package polls until the server responds before running tests. The & runs the dev server in the background.
For faster CI runs, use next build && next start instead of next dev. The production server starts faster and avoids compilation overhead during tests.
Common failures and fixes
SDK Reference
Matchers, flow builder, and buildRequest() — full API reference for the TypeScript SDK.