No signatures found matching the expected signature for payload

Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.

What you see

Checkout works and the payment shows up in Stripe, but your app never unlocks the plan or marks the order as paid. In your server logs you find:

Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.

Or, in Stripe Workbench → Webhooks → your endpoint → Event deliveries, events show as Failed or Pending with a status like 401, 404, 302 or Timed out.

First check which case you are in: open Event deliveries and click a failed event to see the HTTP status code of each attempt.

Why it happens

Stripe signs every webhook with the Stripe-Signature header. Your code checks that signature with three inputs: the raw request body, the signature header, and your endpoint’s signing secret (whsec_…). Stripe’s docs say that if you get this error, at least one of those three is wrong. In AI-generated code it is usually one of two things:

  1. Wrong secret. Stripe calls this the most common cause. The secret printed by stripe listen (local testing) is different from the secret of the endpoint you created in the Dashboard. Each endpoint has its own secret, so a new or recreated endpoint means a new whsec_.
  2. The body was parsed before verification. The signature is computed over the exact bytes Stripe sent. If a framework turns the body into JSON first (re-ordering keys or stripping whitespace), verification fails even with the right secret.

Fix 1: use the right signing secret

  1. In Stripe Workbench, open Webhooks, click the endpoint that points at your production URL, and click Reveal secret.
  2. Put that value in your host’s environment variables (e.g. STRIPE_WEBHOOK_SECRET) and redeploy.
  3. For local testing, use the separate secret that stripe listen --forward-to http://localhost:3000/api/webhooks prints in the terminal. Don’t ship that one to production.
  4. Log the first and last few characters of the secret your code actually reads and compare. Stripe specifically warns about extra whitespace when pasting.

Fix 2: verify the raw body

Next.js App Router (v0, Cursor): read the body with await req.text(), not req.json(). This is Stripe’s own example:

export async function POST(req: Request) {
  const sig = (await headers()).get('stripe-signature');
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      await req.text(),               // raw body, not req.json()
      sig as string,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return NextResponse.json({ message: `Webhook Error: ${(err as Error).message}` }, { status: 400 });
  }
  // handle event.type here, then:
  return NextResponse.json({ message: 'Received' }, { status: 200 });
}

Next.js Pages Router: Stripe says to disable bodyParser for the route and read the body with buffer(req).

Express: register the webhook route before app.use(express.json()) and give it express.raw(), as in Stripe’s Express example. Express runs middleware in order, so if express.json() comes first it parses the body before you verify it.

app.post('/webhook', express.raw({ type: 'application/json' }), handler);
app.use(express.json()); // other routes after

Fix 3: Supabase Edge Functions (common in Lovable and Bolt apps)

Two separate problems show up here:

  • Deliveries fail with 401. Edge Functions require a valid JWT by default, and Stripe doesn’t send one. Supabase’s docs use exactly this case as the example for turning the check off for that one function in supabase/config.toml:

    [functions.stripe-webhook]
    verify_jwt = false

    The function is then callable by anyone, which is why the signature check below is not optional.

  • Signature verification in Deno. Supabase’s example reads the body with req.text() and uses the async verifier with a Web Crypto provider:

    const cryptoProvider = Stripe.createSubtleCryptoProvider();
    const body = await req.text();
    const event = await stripe.webhooks.constructEventAsync(
      body,
      req.headers.get('Stripe-Signature')!,
      Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
      undefined,
      cryptoProvider
    );

Fix 4: deliveries failing (status codes in Workbench)

From Stripe’s troubleshooting table:

  • 3xx (e.g. 302): Stripe treats redirects as failures. Register the final URL (watch www vs bare domain, http vs https, trailing slash).
  • 4xx: the URL doesn’t exist (404), is behind auth (401/403), or doesn’t accept POST (405). Make sure the route is public and accepts POST.
  • 5xx: your handler crashed. Read your app logs.
  • Timed out: return a 2xx quickly and do slow work (emails, database updates) after responding or in a background job.
  • TLS error: Stripe needs TLS 1.2 or higher with a valid certificate.

Registered endpoints must be publicly reachable HTTPS URLs, so a localhost URL or a preview deployment behind password protection won’t work.

Don’t do this

  • Don’t skip verification to “make it work”. Stripe warns that without it, anyone can send fake events to fulfil orders or grant access.
  • Don’t trust the debug fields (secret, destination, payload) that some deliveries include in the Stripe-Signature header. They aren’t signed; only the v1 signature counts.
  • Don’t set the timestamp tolerance to 0. That disables the replay check entirely. The libraries default to 5 minutes, so keep your server clock in sync.

Missed events while it was broken

In live mode Stripe retries failed deliveries for up to three days (sandbox retries are fewer, over a few hours). To recover sooner, list events with the List Events API using delivery_success=false and process them yourself. Make your handler skip events it has already processed, because Stripe’s retries can still arrive.

Check it worked

  1. Run stripe trigger payment_intent.succeeded (or another event type your endpoint listens for) against your sandbox setup.
  2. In Workbench → Event deliveries, the event shows Delivered with status 200.
  3. Open a previously failed event in the Dashboard and click Resend (Stripe allows this for up to 15 days after the event was created), then confirm your app updates.