Your Next.js route returns 400 because stripe.webhooks.constructEvent() threw. It throws when the body, the Stripe-Signature header, or the secret isn't exactly what Stripe used to sign the event. In Next.js the usual culprit is the body.
If the error starts with Webhook payload must be provided as a string or a Buffer (https://nodejs.org/api/buffer.html) instance representing the _raw_ request body, you passed something that isn't a string, Buffer or Uint8Array. In Next.js that's usually one of these:
await req.json(), or req.body (a ReadableStream, not the text). Use await req.text(), or Buffer.from(await req.arrayBuffer()).req.body while Next.js's default body parser was on, so it's already a parsed object. Turn bodyParser off and read the stream, as shown below.Read the body with await req.text(). Don't call req.json() and then JSON.stringify it, because the re-serialized string won't match the signed bytes.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const body = await req.text(); // raw body, unparsed
const sig = req.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// handle event.type ...
return new Response(null, { status: 200 });
}
App Router route handlers don't use the export const config = { api: { bodyParser: false } } setting. That only applies to the Pages Router.
Next.js parses the body by default here. Turn that off for the webhook route and read the stream yourself:
export const config = { api: { bodyParser: false } };
async function rawBody(req) {
const chunks = [];
for await (const c of req) chunks.push(typeof c === 'string' ? Buffer.from(c) : c);
return Buffer.concat(chunks);
}
export default async function handler(req, res) {
const buf = await rawBody(req);
try {
const event = stripe.webhooks.constructEvent(buf, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
res.status(200).end();
} catch (err) {
res.status(400).send(`Webhook Error: ${err.message}`);
}
}
Fixing the raw body is step one. The guide covers every common Stripe webhook failure with copy-paste fixes. The kit is a self-hosted Cloudflare Worker relay that verifies Stripe signatures, then retries, replays or dead-letters each event instead of dropping it.
Fix Your Stripe Webhooks guide · Self-Hosted Webhook Relay Kit. Code LAUNCH40 = 40% off until Sun Oct 11, 6:40 AM MT.
If your webhook lives in a Python backend instead, the rule is the same: verify the raw bytes, not a parsed model. Python doesn't raise the “string or Buffer” error; a parsed body just fails with “No signatures found matching…”.
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
event = stripe.Webhook.construct_event(payload, request.headers.get("stripe-signature"), endpoint_secret)
Full FastAPI and Django handlers: stripe.SignatureVerificationError in Python.
whsec_. It isn't the endpoint ID (we_...) or your API key.stripe listen prints its own whsec_, and it's different from the secret on the endpoint you created in the Dashboard. Production needs the Dashboard one.If Stripe shows a 307 or 308 instead of a 400, the request never reached your handler. See Stripe webhook 307/308 redirect.
For a walkthrough of all five causes across frameworks, read Stripe webhook signature verification failed: the 5 causes and fixes on dev.to.
Free download: Webhook debugging cheat sheet.
Still failing? Paste your payload, header and secret into the free Stripe signature checker. It runs in your browser and tells you which cause it is.
All five common causes with copy-paste fixes: Fix Your Stripe Webhooks guide ($9). Retries, replay and a dead-letter list in front of your endpoint: the self-hosted relay kit ($19).