Why this happens
Stripe signs the exact bytes it sends. Verification needs three things to match, as Stripe's docs put it:
- The raw request body, unchanged. If your framework parses it to JSON and you re-stringify it, the bytes differ and the signature fails.
- The
Stripe-Signatureheader, in the formatt=...,v1=.... - That endpoint's signing secret (
whsec_...). Every endpoint has its own secret, and test and live endpoints are different. The secretstripe listenprints 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.
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.