48-hour launch: 40% off with code LAUNCH40, Guide $5.40 / Kit $11.40

Stripe webhook 400 in Next.js: "No signatures found matching the expected signature for payload"

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.

“Webhook payload must be provided as a string or a Buffer”

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:

App Router (app/api/.../route.ts)

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.

Pages Router (pages/api/...)

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.

Same handler in FastAPI

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.

Works locally, fails on Vercel?

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).