HeHeedza

How do I fix Stripe's "No signatures found matching the expected signature for payload"?

Last reviewed

Short answer

One of the three values you pass to constructEvent is wrong. Usually the body has already been parsed as JSON instead of kept raw, or the whsec_ secret belongs to a different endpoint or mode. In the Next.js App Router, read the body with await req.text() and use the signing secret of that exact endpoint.

Why this happens

Stripe signs the exact bytes it sends. Verification needs three things to match, as Stripe's docs put it:

  1. The raw request body, unchanged. If your framework parses it to JSON and you re-stringify it, the bytes differ and the signature fails.
  2. The Stripe-Signature header, in the format t=...,v1=....
  3. That endpoint's signing secret (whsec_...). Every endpoint has its own secret, and test and live endpoints are different. The secret stripe listen prints is different again from the one in the Dashboard.

Vibe-coded apps often get the body wrong (an Express express.json() that runs before the webhook route) or the secret wrong (the test whsec_ deployed to production, or a trailing newline pasted into the env var).

How to fix it, step by step

1. Next.js App Router (app/api/stripe/webhook/route.ts):

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text(); // raw body; don't call req.json()
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
  }

  // handle event.type here
  return new Response(null, { status: 200 });
}

The App Router doesn't need any bodyParser config. In the Pages Router, disable the body parser (export const config = { api: { bodyParser: false } }) and read the raw buffer.

2. Express: register the webhook route with a raw parser before express.json():

app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
  // ...
  res.sendStatus(200);
});
app.use(express.json());

3. Supabase Edge Function / Deno: use the async verifier with the Web Crypto provider:

import Stripe from 'npm:stripe';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();

Deno.serve(async (req) => {
  const body = await req.text();
  const signature = req.headers.get('Stripe-Signature')!;
  try {
    const event = await stripe.webhooks.constructEventAsync(
      body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!, undefined, cryptoProvider);
    // ...
    return new Response(null, { status: 200 });
  } catch (err) {
    return new Response((err as Error).message, { status: 400 });
  }
});

Deploy it so Stripe can call it without a Supabase JWT: set verify_jwt = false for this function in supabase/config.toml (under [functions.stripe-webhook]), or deploy with supabase functions deploy stripe-webhook --no-verify-jwt. Your signature check is what protects it.

4. Check the secret. Open the endpoint in the Stripe Dashboard (Workbench → Webhooks), click Reveal secret, and compare it with what production actually uses. Re-paste it without spaces or a trailing newline, then redeploy so the new value is loaded.

5. Resend a failed event from the endpoint's Event deliveries tab and confirm you get a 200.

Fix prompt for your AI builder

Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.

Fix prompt
Our Stripe webhook fails with "No signatures found matching the expected signature for payload". Fix only the webhook handler:
1. Read the raw request body (await req.text() in a Next.js route handler, or express.raw for that route in Express, or req.text() plus constructEventAsync with Stripe.createSubtleCryptoProvider() in a Supabase Edge Function). Never parse JSON before verifying.
2. Verify with stripe.webhooks.constructEvent (or constructEventAsync) using the Stripe-Signature header and process.env.STRIPE_WEBHOOK_SECRET.
3. Return 400 on verification failure and 200 after the event is handled.
Don't log the secret, and don't change other API routes.

How to catch this next time

Webhook failures are invisible from the front end. Checkout succeeds, and the customer just never gets access. Check the endpoint's Event deliveries tab after every deploy that touches payments or env vars.

Heedza covers the customer-facing side: it checks on a schedule that your live checkout page loads and shows its payment form or pay button, stopping before payment, and emails a screenshot and a fix prompt if it breaks. It doesn't send webhooks or see your Stripe Dashboard. Try the free scan on your live URL.

Questions

I copied the secret from stripe listen. Why does production fail?

The CLI secret only works for events the CLI forwards. A Dashboard endpoint has its own whsec_ secret, and test and live endpoints differ too.

Do I need bodyParser: false in the App Router?

No. In the App Router, await req.text() gives you the raw body. The bodyParser setting only applies to the Pages Router.

Why does it fail only in my Supabase Edge Function?

Deno uses the Web Crypto API, so use constructEventAsync with Stripe.createSubtleCryptoProvider(). Also deploy with --no-verify-jwt, otherwise Supabase rejects Stripe's request with a 401 before your code runs.

Sources