Demo, all content is generated
Question

Stripe webhook: "No signatures found matching the expected signature" but only on Vercel

Solved · 983 views · asked by rosa_m · edited

Locally everything works with stripe listen. Deployed to Vercel and every webhook fails with:

StripeSignatureVerificationError: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?

My route (app router, Cursor wrote it):

export async function POST(req: Request) {
  const body = await req.json();
  const sig = req.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(
    JSON.stringify(body),
    sig,
    process.env.STRIPE_WEBHOOK_SECRET!
  );
  ...
}

The weird part is it DID work locally. What is different on Vercel?

What I’ve tried

Checked the env var is set in Vercel, redeployed twice, asked Cursor to fix it and it added export const config = { api: { bodyParser: false } } which changed nothing.

Comment
Which secret is in STRIPE_WEBHOOK_SECRET on Vercel, the one stripe listen printed or the one from the Dashboard endpoint page? tobiasw · edited
The one from stripe listen... is that different? rosa_m · edited

3 answers

Marked as helpful by the asker
tobiasw · edited

Two separate problems, and you probably have both.

1. You're re-serializing the body. req.json() followed by JSON.stringify() does not give you the bytes Stripe signed (key order, whitespace and unicode escaping can differ). Use the raw text:

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature");
  if (!sig) return new Response("missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response("bad signature", { status: 400 });
  }
  // handle event...
  return new Response("ok");
}

That export const config = { api: { bodyParser: false } } is a Pages Router thing. It does nothing in the App Router, delete it.

2. The secret is different per endpoint. The whsec_... that stripe listen prints is only for the CLI. Your deployed endpoint (Developers > Webhooks > your endpoint > Signing secret) has its own. Put that one in Vercel and redeploy (env changes need a new deployment).

It "worked" locally because the CLI secret matched and the payload happened to survive the stringify round trip.

Comment
It was both. Had the CLI secret in Vercel. Switched to req.text() + the dashboard secret and the Dashboard now shows 200s. Thank you!! rosa_m · edited
+1 for the stringify thing, it bites everyone once. wes_codes · edited
jonas_k · edited

Small addition: if you have a middleware.ts that matches /api/:path*, make sure it doesn't touch the body or redirect the webhook route (e.g. auth middleware redirecting to /login gives a 307 and Stripe marks it failed). Exclude the webhook path in the matcher.

Comment
no middleware, but good to know rosa_m · edited
kofi_mensah · edited

One more cause I hit: the endpoint URL in Stripe was mysite.com/api/webhook but Vercel redirects to www.mysite.com. Stripe does not follow redirects, a 307/308 counts as a failed delivery. Use the exact final URL.

Comment