My Customer Paid but Nothing Happened in My App, Stripe Webhook Not Firing
6 min read
Someone paid, Stripe took the money, and your app did nothing: no account upgrade, no unlock, no email. The payment worked. The problem is that Stripe's "this got paid" message never reached your app, so nothing downstream ran. That message is called a webhook, and when it doesn't arrive, the customer is charged but stuck.
Why this happens
Stripe Checkout runs on Stripe's servers, not yours. When the card clears, Stripe
sends your server a separate event (usually checkout.session.completed) to a URL
you registered. Your code is supposed to catch that event and do the real work:
grant access, mark the order paid, send the receipt.
In a vibe-coded app this link is the thing that quietly goes missing. The agent
built the checkout button because you asked for "let users pay." It may never have
built the webhook endpoint, or it built one and you never registered its URL in
Stripe, or you registered a localhost URL that Stripe cannot reach in
production. The checkout works in every demo, so nobody notices the second half
was never wired up.
How to check
- Open the Stripe Dashboard, go to Developers → Events, and find the
payment. If you see a
checkout.session.completedevent, Stripe did its part. - Go to Developers → Webhooks and click your endpoint. Look at the recent
deliveries. Three things tell you what's wrong:
- No endpoint listed at all means Stripe has nowhere to send the event.
- Deliveries failing with a red status (4xx or 5xx) mean the event reached your server and your code rejected or crashed on it.
- No delivery attempts mean the event type your code needs isn't one this endpoint is subscribed to.
- Check you're looking at the right mode. The Test / Live toggle matters: a real customer paid in live mode, so a webhook registered only in test mode never fires. See test mode vs live mode.
The fix
- Create the endpoint in Stripe. In Developers → Webhooks → Add endpoint,
enter your real deployed URL, for example
https://yourapp.com/api/stripe/webhook. Not localhost, not a preview URL that changes on every deploy. - Subscribe to the right events. At minimum add
checkout.session.completed. If you sell subscriptions, also addinvoice.paidandcustomer.subscription.updated. - Confirm your server has a route at that exact path that returns a
200quickly. If the path in Stripe and the path in your code don't match character for character, every delivery 404s. - Set the signing secret. Stripe shows a
whsec_...secret for the endpoint. Put it in your server environment (for exampleSTRIPE_WEBHOOK_SECRET) and make sure your handler reads it. A missing secret makes verification fail and your handler reject good events. If deliveries fail specifically on the signature, see signature verification failing. - Replay the failed event. In the dashboard, open the event and click Resend. Watch your server logs and confirm the access actually gets granted this time.
The trap to avoid
Don't "fix" this by granting access on the success page the customer lands on after paying. That page is in the browser, so anyone can open the URL directly and unlock your product without paying, and customers who close the tab too early get nothing. The webhook is the source of truth precisely because it comes from Stripe's server, not the customer's browser. Wire the fulfilment to the webhook, and use the success page only to say "thanks, check your email."
Where this fits
A silent webhook is the classic gap between "payments work in the demo" and "a real stranger's money is stuck." It never shows up until someone actually pays, which is the worst time to find out. The free Readiness Report checks whether your paid events reach your app and actually grant what the customer bought, and flags fulfilment that runs in the browser instead of on your server. If you'd rather have the endpoint, events, and secret set up and verified for you, that's what the Finishing Pass covers. While you're here, make sure a single payment can't fire twice: duplicate webhook events.