A customer pays, Stripe shows the payment as succeeded, and your app does not give access. If your server log shows No signatures found matching the expected signature for payload, the payment is not the problem. Stripe sent the event to your app, and your app rejected it because the signature check failed.
Stripe’s documentation names the most common cause: the wrong endpoint secret. If the app worked in test mode and broke at launch, check that first. Test and live endpoints have different secrets.
TL;DR
- The error means your app rejected a real Stripe event because the signature did not match. The customer’s payment went through.
- Check the secret first. Test and live endpoints have different secrets, every endpoint has its own, and the Stripe CLI prints another one.
- If the secret is correct, your framework probably changed the request body before the check. After the fix, Stripe retries failed live events for up to three days. You can resend older events from the Dashboard.
What you see
The same failure looks different depending on where you look.
In the app: the customer paid, but their account is still locked, the order is not marked as paid, or the subscription does not show.
In Stripe: the payment is Succeeded. In Workbench, open Webhooks, select your endpoint and open the Event deliveries tab. The events show as Failed with a 400 status.
In your server log, with the Node.js library (stripe-node):
No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? If a webhook request is being forwarded by a third-party tool, ensure that the exact request body, including JSON formatting and new line style, is preserved.
Stripe’s own example handlers print a shorter form:
Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.
What does “No signatures found matching the expected signature for payload” mean?
Stripe signs every webhook it sends. It computes an HMAC with SHA-256 over the delivery timestamp and the request body, using the endpoint’s signing secret (it starts with whsec_) as the key. It puts the result in the Stripe-Signature header.
Your code does the same calculation with three inputs: the raw body, the header and the secret. If any one of them is different from what Stripe used, no signature matches. Stripe’s troubleshooting page says it directly: when you get this error, at least one of the three parameters is incorrect.
So there are three places to look, in this order: the secret, the body, the header.
Why a Stripe webhook works in test mode and fails in production
Test mode and live mode run the same code with different secrets. Stripe generates a unique secret for each endpoint. If one endpoint receives both test and live events, the test secret and the live secret are different. The Stripe CLI uses a third secret of its own.
| Where the secret comes from | What it verifies | How it ends up in the wrong place |
|---|---|---|
| The test or sandbox endpoint in the Dashboard | Test events only | Copied into the production environment during setup and never replaced |
| The live endpoint in the Dashboard | Live events only | Never copied, or added only to a preview environment |
stripe listen in the Stripe CLI |
Events forwarded to your local machine | Left in a .env file that was deployed |
| A second endpoint, for example after a URL change | Events sent to that endpoint only | The app still verifies with the first endpoint’s secret |
| A rolled secret | Events after the roll | The old secret is still deployed after it expired |
When you roll a secret in Workbench, you can expire the old one at once or keep it active for up to 24 hours. During that window, Stripe sends one signature per active secret. If the new value is not live in your app before the old one expires, every event fails.
The deployment can also be older than the setting. On Vercel, a change to an environment variable applies only to new deployments. The deployment that is already running keeps the old value. If you pasted the correct live secret and did not redeploy, the running app still has the old value. We explain the same trap for public variables in environment variable changed but production not updated.
When the secret is correct: the body changed before the check
The signature covers the exact bytes Stripe sent. If a framework parses the body into JSON, adds or removes whitespace, reorders keys or changes the encoding before your code verifies it, the check fails even with the correct secret.
| Setup | What goes wrong | What to do |
|---|---|---|
| Express | app.use(express.json()) runs before the webhook route and parses the body |
Register the webhook route before express.json(), or give that route a raw body parser |
| Next.js, Pages Router | The default body parser has already parsed the request | Disable bodyParser for the webhook route and read the raw buffer |
| Next.js, App Router | The code calls request.json() |
Read the body with await request.text() |
| Supabase Edge Functions | The code parses JSON first, or uses the synchronous check | Read with await req.text() and verify with constructEventAsync |
| A proxy or forwarding tool | The body is re-serialized on the way | Forward the exact body, including JSON formatting and line endings |
A Next.js App Router handler that keeps the body intact:
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response("Invalid signature", { status: 400 });
}
// Handle the event, then return 200 quickly.
return new Response(null, { status: 200 });
}
Supabase Edge Functions, including Lovable apps
Lovable apps built on Supabase often receive Stripe webhooks in a Supabase Edge Function. That runtime needs the asynchronous check with a Web Crypto provider, as in the Supabase example:
const cryptoProvider = Stripe.createSubtleCryptoProvider();
const signature = req.headers.get("Stripe-Signature");
const body = await req.text();
const event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get("STRIPE_WEBHOOK_SIGNING_SECRET")!,
undefined,
cryptoProvider
);
The function must also be deployed with verify_jwt = false. Stripe does not send a Supabase JWT, and the signature is what proves the request came from Stripe. If JWT verification is on, the request is rejected before your code runs. Event deliveries then shows 401, and your function log does not show the signature error at all.
Other messages that look like the same problem
| What you see | What it means |
|---|---|
Unable to extract timestamp and signatures from header |
The Stripe-Signature header is missing, or your code reads it incorrectly. A correct header looks like t=...,v1=.... |
Timestamp outside the tolerance zone |
The signature matched, but the delivery is older than the allowed window. The library default is 5 minutes. Check the server clock. |
An error that tells you to use await constructEventAsync(...) |
The runtime only supports asynchronous crypto, for example Deno or an edge runtime. Switch to constructEventAsync. |
401, 403 or 405 in Event deliveries, nothing in your log |
Something blocked the request before your code ran: an auth check, middleware or a platform setting. |
3xx in Event deliveries |
The URL redirects, for example from the bare domain to www. Stripe treats a redirect as a failure. Register the final URL. |
Confirm which input is wrong: 4 checks
- Read the status code. In Workbench, open Webhooks, select the live endpoint and open Event deliveries. Click a failed event. A
400with the signature message means your code rejected it, so continue. Any other code, use the table above. - Compare the secrets. On the same endpoint, click Reveal secret. Confirm that the endpoint is the live one and that its URL points to the app you deployed. Then compare it with the value your running app uses. Log only the first and last four characters, never the full secret.
- Confirm the running deployment has that value. If you changed the variable after the last deploy, redeploy. On Vercel, also check that the variable is set for the Production environment. A value set only for Preview does not reach production.
- Check the body. Just before verification, log the type and length of the body your code passes in. It must be the raw string or buffer. If it is an object, or if its length is different from the delivery Stripe shows, the framework changed it.
After the fix: the customers who paid while it failed
The customers’ payments are safe in Stripe. The events that failed are still waiting.
- In live mode, Stripe retries a failed event for up to three days, with exponential back-off. When your fix is live, the next retry succeeds. Event deliveries shows the time of the next retry.
- For events older than that, open the event in the Dashboard and click Resend. This works for up to 15 days after the event was created. With the Stripe CLI,
stripe events resendworks for up to 30 days. - To find every event that failed, call the List Events API with
delivery_success=false. It returns events from the last 30 days. - In a sandbox, Stripe retries only three times over a few hours.
A manual resend does not cancel the automatic retries, so the same event can arrive twice. Before you resend anything, make sure your handler records each event ID and skips IDs it has already processed. Otherwise a customer can get access twice, or be credited twice.
When the backlog is clear, check it from the business side too: list the customers who paid during the failure window and confirm that each one has access.
Prevent the same incident
- Keep the test secret and the live secret in separate, clearly named variables, scoped to separate environments.
- After every launch or secret change, make one real live payment and watch it reach
Deliveredin Event deliveries. - When you roll a secret, use the 24-hour overlap. Deploy the new value before the old one expires.
- Alert on failed deliveries. A signature failure does not stop the payment, so nobody notices until a customer writes to you.
- Keep signature verification on. Without it, anyone who finds the URL can send a fake event and unlock access.
When this needs a wider diagnosis
If Event deliveries shows 200 and the customer still has no access, the signature is not the problem. The event arrived, and the handler did not do what you expected. Check which event type your code grants access on, whether the endpoint is subscribed to that event type, and whether the handler finds the right user record.
Stripe also does not guarantee that events arrive in order, and it can send the same event more than once. If customers are charged twice or get access twice, that is a separate failure: two payments, or one payment fulfilled twice.
We checked each Stripe claim in this article against the Stripe documentation and the stripe-node source on 2026-09-25, and the Supabase and Vercel claims against their documentation on the same day. App Rescue Desk is independent and is not affiliated with Stripe, Supabase, Vercel or Lovable.
Frequently asked questions
Why does my Stripe webhook work in test mode but not in production?
Because the secret is different. Stripe gives each endpoint its own signing secret, and the test and live secrets for the same endpoint are different. An app that verifies live events with the test secret rejects every one of them.
Is the customer’s payment lost when the webhook fails?
No. The payment succeeded in Stripe. Only the event that tells your app about it failed. Stripe retries live events for up to three days, and you can resend an event from the Dashboard for up to 15 days.
Can I turn off signature verification to make it work?
Do not do that. Without verification, anyone can send a fake event to your endpoint and trigger actions such as granting access or marking an order as paid. Fix the secret or the body, and keep the check.
Where do I find the webhook signing secret?
In Workbench, open Webhooks, select the endpoint and click Reveal secret. It starts with whsec_. If you use stripe listen, the CLI prints its own secret in the terminal, and that one works only for forwarded events.
Still failing?
If payments still do not unlock access after these checks, send the bug fix request. We agree the scope and price with you before we change anything in production. The full service is described on the bug fixing service page.