← All field notes
App Security

Stripe Webhook Works in Test but Fails in Production: 8 Causes in Order

Your Stripe webhook passes every test and then fails for real customers. Eight causes, in the order to rule them out, with the exact thing to look at for each.

Matthew TurleyNovember 5, 20266 min read

Everything passed on your machine. The test payment fired the webhook, the order appeared, the email went out. Then the first real customer paid and nothing happened. When a Stripe webhook works in test and fails in production, the code is rarely the problem. The difference between the two environments is.

Open the failed event in Stripe first (Developers, then Webhooks, then your live endpoint, then the event). It shows the HTTP status your server returned and the response body. That one field tells you which cause below to start with. Work through them in order.

1. You are looking at the wrong mode

Stripe keeps test mode and live mode completely separate: separate API keys, separate webhook endpoints, separate events. A payment made with a real card appears only under live mode, and a live endpoint must exist for it to be delivered. Check the mode toggle in the dashboard before anything else. If there is no live endpoint, there is nothing to debug yet.

2. The signing secret belongs to a different endpoint

Each webhook endpoint has its own signing secret, starting with whsec_. The one printed by stripe listen on your laptop is different from the one on the dashboard's test endpoint, which is different from the live endpoint's. If production still carries the CLI secret, every real event fails verification.

# See which secret production is actually using, without printing it
printenv STRIPE_WEBHOOK_SECRET | cut -c1-10

Compare the first characters with the live endpoint's secret in the dashboard (reveal it there). A mismatch is the single most common reason for a 400 with "No signatures found matching the expected signature".

3. Test-mode keys are still set in production

The Stripe secret key and the webhook secret both change when you go live. A production environment that has sk_test_ for the API key and whsec_ from the live endpoint, or the other way around, fails in confusing half-working ways: the webhook arrives, then every API call inside the handler is rejected or hits test data. Confirm the key prefix in production is sk_live_, and that it never appears in your client bundle (the Stripe key leak check explains how to check).

4. The body was changed before verification

Stripe signs the raw bytes it sent. If your framework parses the body as JSON first and you verify the re-serialized text, the signature will not match even though the secret is right. This works in some local setups and fails behind others.

// Next.js route handler: read the raw text, not req.json()
const raw = await req.text()
const event = stripe.webhooks.constructEvent(raw, req.headers.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!)

In Express, mount the webhook route with express.raw({ type: 'application/json' }) before any global JSON parser. In the Next.js Pages Router, turn the body parser off for that route.

5. The URL redirects

Register the exact final URL. A redirect from http to https, from www to the apex domain, or to a path with a trailing slash returns a 3xx, which is not the 2xx Stripe is waiting for, so the delivery counts as failed. Test with curl -i -X POST https://yourapp.com/api/stripe/webhook and read the status line. You want to see your handler's own response (a 400 for a missing signature is fine here), not a 301 or 308.

6. A login wall or firewall is answering instead of your code

Middleware that protects every route except a few public ones will treat Stripe as an anonymous visitor and return a login page. Bot protection and web application firewalls can challenge server-to-server POSTs. Preview deployments often sit behind deployment protection. Add the webhook route to the public list, and confirm in the delivery log that the response body is your handler's, not an HTML page.

7. The handler is too slow

Stripe expects a quick 2xx. A handler that calls your CRM, sends an email and generates a PDF before answering can run past the timeout, especially on a cold serverless start. Stripe then records a failure and retries, and you may process the same event twice. Respond fast and do the slow work after, from a table you control. The pattern is written out in Paid order never reached your CRM.

8. Retries hid the problem in test and run out in production

In test mode Stripe retries a failed delivery 3 times over a few hours. In live mode it keeps trying for up to 3 days with exponential backoff1. A handler that fails occasionally can appear healthy in testing, then lose real events when it fails and the retries do not line up with when your service recovers. Make the handler safe to run twice: key your records on the Stripe event ID or payment ID, with a unique constraint, so a retry updates the same row.

Prove it with one real payment

After you fix the likely cause, do not trust a test event. Make one small real payment in live mode with your own card, watch the delivery succeed in the dashboard, confirm the order appears in your own database, then refund it. Test events prove the wiring. A real payment proves the environment.

You can also replay a failed live event from the dashboard once the fix is deployed, which processes the original event through your corrected handler.

What the free check and the $47 scan can and cannot tell you

Neither can call your webhook. Stripe sends the signed event, and an outside scan cannot imitate it. What the free Leak Check can show is the outside view that causes some of the problems above: whether your live URL redirects, whether SSL is valid, and whether a secret key is exposed in the client bundle.

The $47 Complete Scan also looks at the auth endpoints and exposed config on your live domain, which is where a login wall or leftover config problem would show from outside. It does not send events to your webhook and cannot confirm that your handler works.

Next step

Open your latest failed live event in Stripe and read the status code. Match it to the list above. Then run the free Leak Check on your production URL to confirm nothing exposed is riding along with the problem. If you want the whole payment path read from the inside, the handler, the keys and the database policies, that is the Ship Check.

Related: your app took the payment but no order showed up and zombie container failing deploys silently.

Sources

Market figures above are marked with a number that links here. Our own prices are marked as ours and come from our published pricing.

  1. 1. Set up a webhook endpoint (Workbench), Stripe, checked November 5, 2026. Stripe documentation: in live mode Stripe attempts event delivery for up to 3 days with exponential backoff; in test mode it retries 3 times over a few hours.

Worried your app has holes like these?

Drop your email and we will run a free teardown of your stack and send back the leaks we find, no pitch.

M
Matthew Turley, Continuum

Fractional CTO and embedded technical partner. 20+ years shipping production software.

Run a free Leak Check →
Stripe Webhook Works in Test, Fails in Production: Checklist