Integration guide
Verifying Stripe webhooks in Next.js App Router (the raw-body trap)
Why req.json() breaks Stripe signature verification in Next.js App Router, and the correct raw-body pattern with replay tolerance.
The problem
Stripe computes the HMAC-SHA256 signature over the exact bytes of the request body. In the Next.js App Router, calling await req.json() parses and re-serializes the body — key ordering and whitespace can change — so the signature you verify no longer matches the bytes Stripe signed.
The fix is to read the body as text first, verify the signature over that exact string, and only then parse it.
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
}
// Read unparsed raw text — the exact bytes Stripe signed.
const rawBody = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
300 // replay tolerance: reject timestamps older than 5 minutes
);
} catch (err: any) {
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
}
// Idempotency: record event.id so a redelivery is a no-op.
// ... your handler ...
return NextResponse.json({ received: true });
}The three failures this prevents
Signature mismatch on every request, because the verified bytes differ from the signed bytes. The endpoint rejects all traffic — including real events.
Replay attacks, because constructEvent without an explicit tolerance uses Stripe's default window, and many hand-rolled verifiers skip the timestamp check entirely.
Duplicate processing, because Stripe retries deliveries and a handler without event-id idempotency provisions the same customer twice.
Verify it
Run the HookCheck suite against your deployed endpoint. The valid_signature vector proves the raw-body path is correct; the expired_timestamp vector proves the tolerance window is enforced; the replay_concurrency vector proves idempotency holds under rapid redelivery.
Test your endpoint
Run the free verification suite against your deployed webhook receiver and confirm all eight vectors pass.
Run the verification suite