afterbuild/ops
ERR-631/Stripe · Webhooks
ERR-631
Webhook signature verification failed

appears when:When the request body is parsed or mutated before the signature check, or the wrong webhook secret is configured for the environment

Webhook signature verification failed

Stripe signs the raw bytes. If your framework parses JSON first, the signature will never match — even if the payload is byte-identical to what Stripe sent.

Last updated 17 April 2026 · 7 min read · By Hyder Shah
Direct answer
The signature is computed over the raw request bytes. Call await req.text() first, pass that string to constructEvent(body, signature, secret). Do not call req.json(), do not rely on body-parser middleware, and verify the STRIPE_WEBHOOK_SECRET matches the mode (test vs live) of the endpoint in the Stripe dashboard.

Quick fix for webhook signature verification failed

app/api/webhooks/stripe/route.ts
typescript
01// app/api/webhooks/stripe/route.ts — App Router raw body pattern02import { NextRequest, NextResponse } from "next/server";03import Stripe from "stripe";04 05const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);06 07export async function POST(req: NextRequest) {08  const body = await req.text(); // raw bytes as string — never req.json()09  const signature = req.headers.get("stripe-signature");10 11  if (!signature) {12    return NextResponse.json({ error: "missing signature" }, { status: 400 });13  }14 15  try {16    const event = stripe.webhooks.constructEvent(17      body,18      signature,19      process.env.STRIPE_WEBHOOK_SECRET!20    );21    // handle event.type here22    return NextResponse.json({ received: true });23  } catch (err) {24    console.error("webhook signature verification failed", err);25    return NextResponse.json({ error: "invalid signature" }, { status: 400 });26  }27}
App Router webhook route — reads raw body, verifies signature, returns 400 on mismatch

Deeper fixes when the quick fix fails

01 · Pages Router: disable body parsing for the webhook route

pages/api/webhooks/stripe.ts
typescript
01// pages/api/webhooks/stripe.ts02export const config = {03  api: {04    bodyParser: false,05  },06};07 08import { buffer } from "micro";09import Stripe from "stripe";10 11const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);12 13export default async function handler(req, res) {14  if (req.method !== "POST") return res.status(405).end();15 16  const buf = await buffer(req);17  const signature = req.headers["stripe-signature"] as string;18 19  try {20    const event = stripe.webhooks.constructEvent(21      buf,22      signature,23      process.env.STRIPE_WEBHOOK_SECRET!24    );25    res.json({ received: true });26  } catch (err) {27    res.status(400).send(`webhook error: ${(err as Error).message}`);28  }29}
Pages Router requires bodyParser: false and the micro.buffer helper for raw bytes

02 · Clerk / Svix webhook verification

app/api/webhooks/clerk/route.ts
typescript
01// app/api/webhooks/clerk/route.ts02import { Webhook } from "svix";03import { headers } from "next/headers";04 05export async function POST(req: Request) {06  const body = await req.text();07  const h = await headers();08  const svixHeaders = {09    "svix-id": h.get("svix-id")!,10    "svix-timestamp": h.get("svix-timestamp")!,11    "svix-signature": h.get("svix-signature")!,12  };13 14  const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);15  try {16    const event = wh.verify(body, svixHeaders);17    return Response.json({ received: true });18  } catch {19    return Response.json({ error: "invalid signature" }, { status: 400 });20  }21}
Same raw-body contract as Stripe; Svix verifies three headers instead of one

03 · Regression test with a signed fixture

tests/webhook.test.ts
typescript
01// tests/webhook.test.ts02import { POST } from "@/app/api/webhooks/stripe/route";03import Stripe from "stripe";04 05test("rejects tampered payload", async () => {06  const payload = JSON.stringify({ id: "evt_test", type: "ping" });07  const secret = "whsec_test";08  const signature = Stripe.webhooks.generateTestHeaderString({ payload, secret });09 10  const req = new Request("http://x", {11    method: "POST",12    headers: { "stripe-signature": signature },13    body: payload + "tampered",14  });15 16  const res = await POST(req as never);17  expect(res.status).toBe(400);18});
Fixture-based test so signature regressions are caught in CI

Why AI-built apps hit webhook signature verification failed

Every major webhook provider — Stripe, Clerk via Svix, GitHub, Shopify — signs the raw bytes of the request body using HMAC-SHA256 with a shared secret. The signature lives in a header. Your server recomputes the HMAC over the body it received and compares. If the two hashes match, the event is authentic. If any byte differs, verification fails.

The failure mode that trips up AI-generated code is body handling. When a Cursor or Lovable scaffold writes a webhook handler, it often reaches for req.json() because that is the pattern used everywhere else in the app. That call reads the stream, parses the JSON, and discards the original bytes. The handler then serializes the parsed object back to a string to pass into constructEvent. The re-serialization reorders keys, changes whitespace, drops trailing newlines. The hash no longer matches what Stripe signed. Verification fails on every request, and the log message is identical to a real attack — so the developer assumes their secret is wrong and rotates it, which does not help.

The second failure mode is secret drift. Stripe shows you a whsec_ secret once when you create the endpoint. If you create a test endpoint and a live endpoint, those are two different secrets. If your production env is pointing at the test secret, or vice versa, the verification fails for real events. Every webhook audit we run finds at least one env variable pointing at the wrong mode's secret.

The third failure mode is middleware. In Pages Router, Next.js parses bodies by default. In App Router, middleware.ts can mutate requests. If anything upstream of your handler reads or modifies the body, the bytes reaching the signature check are no longer what Stripe signed. The fix is always the same: guarantee the raw bytes arrive untouched.

webhook signature verification failed by AI builder

How often each AI builder ships this error and the pattern that produces it.

AI builder × webhook signature verification failed
BuilderFrequencyPattern
LovableEvery Stripe integrationUses req.json() then re-stringifies for verification
Bolt.newCommonHard-codes test whsec_ in production env
CursorCommonAdds body parsing middleware that mutates payload
Base44SometimesSkips verification entirely in development mode
Replit AgentRareConfuses stripe-signature header case

Related errors we fix

Stop webhook signature verification failed recurring in AI-built apps

Still stuck with webhook signature verification failed?

Emergency triage · $299 · 48h turnaround
We restore service and write the root-cause report.
start the triage →

webhook signature verification failed questions

Why does Stripe signature verification fail in Next.js App Router?+
Stripe signs the raw bytes of the request body. If anything in your stack reads the body as JSON first, the bytes it re-serializes will not match what Stripe signed. In Next.js App Router you must call await req.text() before passing the body to stripe.webhooks.constructEvent. Do not use await req.json(), do not access req.body. The text must be read once and passed verbatim alongside the stripe-signature header.
My webhook worked in dev but fails in production — what changed?+
The most common cause is secret swap. Test mode events are signed with a test webhook secret; live mode events are signed with a different secret. Vercel production env is often set to the test secret by mistake, or the live secret rotated and the env variable was not updated. Check the Stripe dashboard endpoint, copy the whsec_ from the correct mode, and redeploy. Also confirm the env var name matches exactly.
Does Clerk webhook signing work the same way?+
Clerk uses Svix for webhook signing. The header name is svix-id, svix-timestamp, svix-signature. The verification pattern is the same: you need the raw body. Use new Webhook(CLERK_WEBHOOK_SECRET) from the svix package, then wh.verify(body, headers). If you parse JSON first or a middleware mutates the payload, Svix rejects it. The 10-minute fix is identical to Stripe's.
Why would body-parser break my webhook?+
Older Express or Pages Router setups ship with body-parser as default middleware. It reads the stream, parses to JSON, and discards the original bytes. By the time your webhook handler runs, the raw bytes are gone. Solutions: disable body parsing for the webhook route with export const config = { api: { bodyParser: false } } in Pages Router, or use App Router where you control the read explicitly.
How long does an Afterbuild Labs webhook audit take?+
For a single webhook endpoint, diagnosis and fix is under 1 hour: we confirm the raw body path, verify secrets match mode, add replay protection, and ship a regression test that pipes a signed fixture through the handler. Full integration audits across Stripe, Clerk, and custom webhooks take 2-3 hours. Our Integration Fix service is fixed-fee at $349 and includes the test suite.
Next step

Ship the fix. Keep the fix.

Emergency Triage restores service in 48 hours. Break the Fix Loop rebuilds CI so this error cannot ship again.

About the author

Hyder Shah leads Afterbuild Labs, shipping production rescues for apps built in Lovable, Bolt.new, Cursor, Replit, v0, and Base44. our rescue methodology.

Sources