Stripe Webhook "No signatures found matching the expected signature": How to Fix It
Payments go through in Stripe, but your app never finds out. Users pay and stay on the free plan. In your function logs, or in Stripe’s event deliveries list, you see:
Webhook signature verification failed.
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
Stripe signs every webhook it sends, and your code recomputes that signature to prove the request really came from Stripe. If anything about the inputs is off, even by one byte, the check fails. Stripe’s docs say it directly: if you see this error, at least one of the three things you passed in is wrong. (Stripe: Resolve webhook signature verification errors)
Those three things are:
- The raw request body, exactly as Stripe sent it.
- The
Stripe-Signatureheader. - The signing secret for that specific webhook endpoint (it starts with
whsec_).
Work through the causes below in order. The first two fix most cases.
Don’t “fix” it by skipping the check. Without signature verification, anyone who finds your webhook URL can send a fake “payment succeeded” event and unlock paid features for free.
Cause 1: the wrong signing secret
This is the most common one, according to Stripe. Every webhook endpoint has its own whsec_... secret, and there are several easy mix-ups:
- Dashboard endpoint vs Stripe CLI. When you test locally with
stripe listen, the CLI prints its own secret in the terminal. It’s different from the secret of the endpoint you created in the Dashboard. Use the CLI secret only with CLI-forwarded events, and the Dashboard secret only with real deliveries. - Test mode vs live mode (or sandbox vs live). Test and live endpoints are separate, with separate secrets. If you switched your app to live keys but kept the test
whsec_, every live event fails. The reverse is also true: sending a test event to a function configured with the live secret fails. - Two endpoints, one secret. If you have more than one endpoint (say, an old one on a preview URL and a new one on your domain), make sure the secret matches the endpoint that’s actually calling this URL.
- Not the API key. The signing secret is not
sk_live_...orsk_test_.... It’s thewhsec_...value shown on the endpoint’s page.
Fix: In Stripe, open Workbench → Webhooks (Stripe’s docs point there), select the endpoint whose URL matches your function, and click Reveal secret. Copy it into your environment variable (for example STRIPE_WEBHOOK_SECRET), then redeploy or restart so the new value is picked up. Stripe’s docs suggest temporarily printing the secret your code uses to confirm it matches. If you do, print only the last few characters, and remove the log afterwards.
For where secrets belong in each platform, see Environment Variables and API Keys, Explained.
Cause 2: the body was parsed before verification
The signature covers the body exactly as sent. If your code (or your framework) turns the body into a JavaScript object first and then back into text, the spacing and key order can change, and the signature no longer matches. Stripe’s docs list reordered keys, stripped whitespace, JSON conversion and encoding changes as typical culprits.
The rule: read the body as raw text, verify it, then parse.
Supabase Edge Functions (Lovable, Bolt and many Cursor apps)
Edge Functions run on Deno. Use req.text(), never req.json(), and use the async verification method with Stripe’s Web Crypto provider. This mirrors Supabase’s official Stripe webhook example:
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@^22'; // pin to the version your project uses
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
const signature = req.headers.get('Stripe-Signature');
if (!signature) return new Response('Missing signature', { status: 400 });
const body = await req.text(); // raw body: do NOT call req.json() first
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
undefined,
cryptoProvider,
);
} catch (err) {
console.error('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 as Stripe.Checkout.Session;
// Record the payment / unlock access, keyed on session.id
}
return Response.json({ received: true });
});
Using the synchronous constructEvent in Deno fails with an error about SubtleCryptoProvider not working in a synchronous context. That’s a different message, but it’s the same fix: use constructEventAsync.
Supabase’s newest example wraps the handler in withSupabase({ auth: 'none' }, ...) from @supabase/server instead of Deno.serve. Either works. The Stripe part is identical. (Supabase example on GitHub)
Next.js App Router
Route handlers give you the raw body with await req.text():
// 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();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// handle event.type ...
return Response.json({ received: true });
}
If this route runs on the Edge runtime instead of Node.js, use await stripe.webhooks.constructEventAsync(...). On the older Pages Router, you have to disable Next’s built-in body parser for the route. Stripe’s docs link to working examples for both.
Express
Order matters. If app.use(express.json()) runs before your webhook route, the body is already parsed. Give the webhook route a raw body and register it first:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // a Buffer, untouched
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return res.status(400).send('Invalid signature');
}
// handle event ...
res.json({ received: true });
});
app.use(express.json()); // JSON parsing for every other route, AFTER the webhook
Also check for these
- Reading the body twice. A body can only be read once. If an earlier line called
req.json()just to log it, the verification gets an empty or “already used” body. - Something in between. Middleware, proxies or “request logging” wrappers that re-encode the body. If your code looks right, temporarily deploy the bare minimum handler above to rule your stack in or out.
Cause 3: Supabase rejects Stripe before your code runs
This isn’t a signature error, but it looks similar in Stripe’s dashboard: every delivery fails with 401, and your function logs show nothing. By default, Supabase Edge Functions require a valid user JWT in the Authorization header. Stripe doesn’t send one.
Supabase’s docs use exactly this case as their example and turn the check off for the webhook function only, in supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = false
(Supabase: Function configuration)
This is safe only because the function verifies Stripe’s signature itself. Don’t turn off JWT checks on functions that don’t have another way to prove who’s calling. In Lovable, you can ask the AI to make this change for the webhook function only.
Cause 4: the header isn’t reaching your code
Print the header (not the secret) once:
console.log(req.headers.get('stripe-signature'));
Stripe’s docs say it should look like t=...,v1=.... If it’s null, something is stripping headers, or you’re testing by sending your own request with curl or Postman. Hand-made requests can’t pass, because they aren’t signed with your secret. Use real deliveries or the Stripe CLI instead.
Cause 5: timestamps and retries
The signature includes a timestamp. Stripe’s libraries reject events more than 5 minutes old by default, to stop replay attacks. Two practical consequences, both from Stripe’s webhook docs:
- If your server’s clock is wrong, valid events fail. Managed hosts handle this for you. On your own server, keep time synced with NTP.
- Retries are fine. When Stripe retries an event, it generates a new signature and timestamp. So you can’t replay an old saved body later and expect it to pass, but Stripe’s own retries will.
Don’t set the tolerance to 0. Stripe warns that this disables the recency check entirely.
Test it properly
- Locally, run
stripe listen --forward-to localhost:3000/api/stripe/webhook(or your function’s local URL), copy thewhsec_it prints, and trigger an event withstripe trigger checkout.session.completed. (Stripe CLI) - In production, open your endpoint in Stripe’s Workbench, go to Event deliveries, and check the response code of recent events. Failed events can be resent from there once your fix is deployed.
- Make a real purchase in test mode, end to end, and confirm your app unlocks access.
After it works: two habits that prevent the next bug
- Return 2xx quickly. Stripe’s docs say to respond with success before doing slow work, so deliveries don’t time out.
- Handle duplicates. Stripe may occasionally deliver the same event more than once. Store the event IDs (or
session.id) you’ve already processed and skip repeats, so nobody gets charged once and credited twice.
The pre-launch security checklist covers the rest of the payment basics. And if you ever pasted your sk_live_ key somewhere public while debugging, rotate it now: what to do if you leaked an API key.
Official sources used: Stripe: Resolve webhook signature verification errors · Stripe: Receive events in your webhook endpoint · Stripe CLI · Supabase: Function configuration · Supabase: Stripe webhook example. Checked October 2026.
Want the whole launch path in one place?
The Ship It Kit bundles copy-paste prompts, step-by-step deploy guides and a pre-launch checklist for Lovable, Bolt, Cursor and Replit apps. One-time $19. Launch price: $19 $17 until November 14, 2026.