Stripe webhooks not working in production
Most “Stripe works in test, breaks in prod” reports are the same few mistakes. Fulfillment on the success page, the wrong webhook secret, or a body parser that breaks signature verification.
If customers pay and never get access, or access unlocks when someone hits a thank-you URL, your webhook path is wrong. Fix that before you market the product.
The rule
Grant entitlements only from a verified webhook event (commonly checkout.session.completed or invoice.paid), after you verify the Stripe signature. The browser success page is a UX nicety. Anyone can open it.
Symptom 1: Works locally with the CLI, fails on the host
Locally you probably run stripe listen --forward-to localhost:… and use the signing secret the CLI prints (whsec_…). Production needs a different signing secret from the webhook endpoint you created in the Stripe Dashboard for your live (or test) mode.
- Dashboard → Developers → Webhooks → your endpoint → Signing secret.
- Put that value in the production environment only.
- Do not reuse the CLI secret on Vercel/Netlify/Fly.
Symptom 2: Signature verification always fails
Stripe signs the raw request body. If your framework parses JSON before your handler runs, verification fails.
- In Next.js App Router, read the raw body (for example
await req.text()) and pass that string intostripe.webhooks.constructEvent. - Disable body parsing for that route if your stack requires an explicit opt-out.
- Confirm the endpoint URL in Stripe matches the deployed path exactly (including trailing slash policy).
Symptom 3: Test mode events hit a live key (or the reverse)
Test and live are separate worlds: keys, webhook endpoints, and customer objects do not mix.
- Production site → live secret key + live webhook endpoint + live signing secret.
- Preview/staging → test key + test webhook (or no payments).
- Search the client bundle for
sk_live/sk_test. Secret keys must never ship to the browser; only publishable keys (pk_) belong there.
Symptom 4: You fulfill on success_url
Patterns to delete:
- “On thank-you page load, mark the user as paid.”
- “Trust a
?session_id=query param without verifying the session server-side and without a webhook.”
Acceptable thank-you page behavior: show a receipt state while you poll your own backend, which only flips to “active” after the webhook has written the entitlement.
Minimal production checklist
- Webhook endpoint created in the same mode as the API key (test vs live).
- Events you care about selected (at least the one that means “money moved / subscription started”).
- Handler verifies signature with the endpoint’s
whsec_. - Handler is idempotent (Stripe retries; processing the same
event.idtwice must not double-grant). - Handler returns 2xx quickly; slow work goes to a queue if you have one.
- Failed deliveries visible in the Stripe Dashboard and in your logs.
How to verify in 15 minutes
- Use Stripe’s “Send test webhook” from the Dashboard against production (careful: use test mode endpoint for dry runs).
- Complete a real small payment in test mode on staging.
- Confirm a row or flag in your database changed only after the webhook, not after loading success URL in an incognito window without paying.
Webhooks are one of the non-negotiables in a vibe-coded launch. For a broader list (secrets, auth, RLS, backups), skim the launch checklist guide or the free preview, or get the full Last Mile kit.
Idempotency and retries (why duplicates happen)
Stripe retries webhooks when your endpoint is slow, returns 5xx, or times out. If your handler inserts an “order paid” row every time, you will double-fulfill.
- Store Stripe
event.id(or the payment intent / session id) with a unique constraint. - On conflict, return 200 without doing the side effect again.
- Log the event type and id; when a customer emails “I paid,” you can search logs and the Dashboard delivery attempts.
Also confirm you are not verifying signatures with the publishable key or with an env var that is empty in production. An empty secret often fails closed (good) or, in buggy custom code, fails open (bad). Write a unit test that feeds an invalid signature and expects a 400.
Checkout Session vs PaymentIntent
Many vibe-coded apps use Checkout Sessions. Fulfill on checkout.session.completed, read client_reference_id or metadata you set when creating the session, and map that to your user id. Do not trust metadata the customer could have edited in the browser; only trust what your server put on the session when creating it with the secret key.
Related guides
- Payment success-page bypass: verify payments server-side
One of the most common bugs in AI-built paid apps: the success page grants access. Anyone who types the URL gets the product free. The fix is a few lines of server code.
- A vibe-coded app launch checklist
Your app runs on a preview URL. Friends can click around. Launch is the moment real users and real money show up. This checklist is the short version of what usually breaks first.
- Leaked API keys in your frontend bundle: how to check
If a secret can call a paid API or bypass your database rules, it does not belong in JavaScript the browser downloads. Here is how to find leaks fast and what to do when you find one.
Get launch notes
$ subscribe --launch-notes # new checks, prompts and shipping tips for vibe coders. No spam.