Stripe webhook error “No signatures found matching the expected signature for payload”: causes and fixes
Why Stripe's signature check fails, the three things that cause it, working code for Next.js and Supabase Edge Functions, and how to make sure a paying customer always gets what they paid for.
By Sameem Amjad
The short answer
Stripe signs every webhook using your endpoint's signing secret and the exact bytes of the request body. Your code recomputes that signature from the body it received and the secret it has, and this error means the two don't match. The full message asks the right question: “Are you passing the raw request body you received from Stripe?” Stripe's troubleshooting page says at least one of three inputs is wrong: the body, the secret or the signature header. Stripe names the wrong secret as the most common cause; a body that was parsed as JSON before it was checked is the other one to look for.
What it costs you: paid, but nothing unlocked
This error is behind one of the most expensive bugs a paid app can have: Stripe takes the customer's money, and the app never unlocks what they paid for. The app should grant access when Stripe's webhook confirms the payment. When verification fails, your endpoint returns an error, the code that records the payment never runs, and the customer is left with a receipt and a locked account.
Two things limit the damage. In live mode, Stripe retries a failed delivery for up to three days (in a sandbox, three times over a few hours), so if you fix the endpoint inside that window, the missed events arrive on their own. After that, open the event in the Dashboard and click Resend, which works for 15 days after the event was created, or run stripe events resend from the CLI, which works for 30.
Don't move the unlock to the success page to dodge the problem. Stripe's guide to fulfilling orders says you “can’t rely on triggering fulfillment only from your checkout landing page”, because customers don't always reach it. Unlocking from that page as well is fine; the webhook is what makes it certain.
Cause 1: the body was parsed before it was checked
Verification needs the raw body. Once anything turns it into a JavaScript object, even turning it straight back into a string produces different bytes (spacing, key order, escaped characters), and the signature can't match. This is the pattern to look for:
// Broken: the body has been parsed, so the original bytes are gone
const body = await req.json();
const event = stripe.webhooks.constructEvent(JSON.stringify(body), signature, secret);In a Next.js App Router route handler, read the body with await req.text() and pass that string straight in:
// app/api/webhooks/stripe/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(); // the raw body, exactly as Stripe sent it
const signature = req.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error('Stripe signature check failed:', (err as Error).message);
return new Response('Invalid signature', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
console.log('Paid checkout session', session.id);
// Record the payment and unlock access here, keyed on session.id
}
return new Response(null, { status: 200 });
}If the route runs on the Edge runtime, call await stripe.webhooks.constructEventAsync(...) with the same arguments instead; the synchronous version can't use the Web Crypto API there.
In the Pages Router, Next.js parses the body for you, so switch that off for the webhook route and read the raw stream:
// pages/api/webhooks/stripe.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Stop Next.js parsing the body of this route
export const config = { api: { bodyParser: false } };
async function readRawBody(req: NextApiRequest): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
await readRawBody(req),
req.headers['stripe-signature']!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send('Invalid signature');
}
console.log('Received', event.type); // handle the event here
return res.status(200).json({ received: true });
}In Express, give the webhook route express.raw() and register it before express.json(), because Express runs middleware in order:
// The webhook route comes first and gets the raw body
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return res.status(400).send('Invalid signature');
}
console.log('Received', event.type); // handle the event here
res.json({ received: true });
});
// JSON parsing for every other route
app.use(express.json());Cause 2: the wrong signing secret
Every webhook endpoint has its own secret starting with whsec_, and it's easy to be using the wrong one:
- The
stripe listensecret only works locally. The CLI prints its ownwhsec_value for the events it forwards. Your deployed app needs the endpoint's secret: in the Dashboard's Workbench, open Webhooks, select the endpoint and click Reveal secret. - Test and live are different. If the same URL is registered in a sandbox and in live mode, each has its own secret, so going live means a new secret in production.
- A recreated endpoint has a new secret. Delete and re-add an endpoint, or roll its secret, and the old value stops working.
- It isn't your API key.
sk_live_andpk_live_keys are not signing secrets. - Stray whitespace. A trailing space or newline pasted into the environment variable breaks the match. Recent versions of Stripe's Node library add a note to the error when the secret contains whitespace.
- The old value is still deployed. On Vercel, a changed environment variable only applies to new deployments, so redeploy after updating it.
The quickest check is the one Stripe suggests: print the secret your code is actually using (the first few characters are enough) and compare it with the endpoint in Workbench.
Cause 3: something in between changed the request
- App-wide body parsing:
app.use(express.json())orbody-parserregistered before the webhook route, or a framework plugin that parses every request. - Proxies and forwarding tools. Stripe's error message covers this directly: if a webhook is forwarded by a third-party tool, “ensure that the exact request body, including JSON formatting and new line style, is preserved.”
- AWS API Gateway in front of Lambda, which needs a mapping template that passes the raw body through. Stripe's troubleshooting page has the template.
Some failures look similar but are a different error. Stripe treats redirects as failures, so an endpoint on example.com that redirects to www.example.com, or an auth middleware that sends /api/webhooks to a login page, fails with a 3xx. A 401 or 403 usually means something is guarding the route. The endpoint's Event deliveries tab in Workbench shows the status code of every attempt, so start there.
Supabase Edge Functions (Lovable and Bolt apps)
Edge Functions run on Deno, where Stripe's check has to be asynchronous and use the Web Crypto API; the synchronous constructEvent fails there with “SubtleCryptoProvider cannot be used in a synchronous context.” Supabase's own Stripe example creates a crypto provider, passes it to constructEventAsync and reads the body with req.text():
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@^22';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);
// Needed to use the Web Crypto API in Deno
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
const signature = req.headers.get('Stripe-Signature');
const body = await req.text(); // never req.json() before verifying
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
undefined,
cryptoProvider,
);
} catch (err) {
return new Response((err as Error).message, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
console.log('Paid checkout session', session.id);
// Record the payment and unlock access here, keyed on session.id
}
return Response.json({ received: true });
});By default, Supabase also requires a valid JWT on every function call. Stripe doesn't send one, so the request is rejected before your code runs. Turn that off for this function only, since the signature check is its security, in supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = falseThen set the secrets and deploy:
supabase secrets set STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_...
supabase functions deploy stripe-webhookRegister https://YOUR-PROJECT.supabase.co/functions/v1/stripe-webhook as the endpoint in Stripe and use that endpoint's secret. Supabase's newest function template wraps the handler in withSupabase({ auth: 'none' }, ...) from @supabase/server instead of Deno.serve; the Stripe part is the same either way.
Answer fast, and make the unlock safe to run twice
Stripe wants a 2xx response quickly, before any slow work, and counts a timeout as a failed delivery. With Checkout and a success page, Stripe waits up to 10 seconds for your webhook to respond before redirecting the customer, so a slow handler also means a slow checkout. Record the payment, return 200, and move anything slow (emails, invoices, syncing other systems) to a background job.
Stripe can also deliver the same event more than once, and a retry after a timeout can arrive when your handler already did the work. So the unlock has to be idempotent: running it twice must leave the same result as running it once. The simplest way is a unique key on the Checkout Session ID, so a second insert does nothing:
create table public.purchases (
stripe_session_id text primary key,
user_id uuid not null references auth.users (id),
created_at timestamptz not null default now()
);
alter table public.purchases enable row level security;Then replace the checkout.session.completed block in the App Router or Edge Function handler above. Here supabaseAdmin is a Supabase client created on the server with your secret (service role) key, and client_reference_id is set to the user's ID when you create the Checkout Session:
if (
event.type === 'checkout.session.completed' ||
event.type === 'checkout.session.async_payment_succeeded'
) {
const session = event.data.object;
if (session.payment_status !== 'unpaid') {
const { error } = await supabaseAdmin
.from('purchases')
.upsert(
{ stripe_session_id: session.id, user_id: session.client_reference_id },
{ onConflict: 'stripe_session_id', ignoreDuplicates: true },
);
// A 500 makes Stripe retry later; a duplicate is not an error
if (error) return new Response('Could not record purchase', { status: 500 });
}
}checkout.session.async_payment_succeeded covers payment methods that confirm later, such as bank debits. The table has RLS on and no policies, so only your server can touch it; add a select policy if users should see their own purchases. My guide to Supabase's “RLS disabled in public” warning shows how.
Test it before a customer does
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# in a second terminal
stripe trigger checkout.session.completed --add checkout_session:client_reference_id=YOUR_TEST_USER_IDFor a Supabase function served locally, forward to localhost:54321/functions/v1/stripe-webhook instead. While stripe listen runs, put the whsec_ value it prints in your local environment. A triggered event creates real test-mode objects, so once the app is deployed, register the endpoint in a sandbox, run the trigger again and check that its Event deliveries tab shows a 200. Then pay once through your own Checkout page in test mode, with card 4242 4242 4242 4242, any future expiry date and any CVC, and confirm the purchase unlocks.
When the webhook isn't the only payment problem
The webhook is the part of AI-built payment code that usually goes wrong, but rarely the only part: prices taken from the browser, subscriptions that never cancel, refunds nobody handles. If you'd like the payment flow checked end to end and fixed, that's part of my app rescue service.