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:
- 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 newwhsec_. - 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
- In Stripe Workbench, open Webhooks, click the endpoint that points at your production URL, and click Reveal secret.
- Put that value in your host’s environment variables (e.g.
STRIPE_WEBHOOK_SECRET) and redeploy. - For local testing, use the separate secret that
stripe listen --forward-to http://localhost:3000/api/webhooksprints in the terminal. Don’t ship that one to production. - 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 = falseThe 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
wwwvs bare domain,httpvshttps, 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
2xxquickly 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 theStripe-Signatureheader. They aren’t signed; only thev1signature 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
- Run
stripe trigger payment_intent.succeeded(or another event type your endpoint listens for) against your sandbox setup. - In Workbench → Event deliveries, the event shows
Deliveredwith status200. - 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.