Build.
Why your Stripe webhook is not working
The payment succeeded and your app never found out. Six causes, and how to tell which is yours.
The money arrives and your app never hears about it. Someone paid, and they are looking at a free account.
Start in the Stripe dashboard, under Developers, then Webhooks, and click your endpoint. Stripe logs every attempt with the response it got. That log tells you which of these six you have.
1. Signature verification is failing
The most common one. Stripe signs each request, and your handler verifies the signature against the raw body of the request, exactly as it arrived.
The trap is that most web frameworks parse JSON bodies automatically. Once parsed and re-serialised, the bytes differ, and verification fails on a perfectly valid request.
Sign: Stripe’s log shows a 400, and your logs mention an invalid signature. Fix: read the raw body for that route. Every framework has a way to disable body parsing on one path.
2. You are using the wrong signing secret
Test mode and live mode have different endpoints with different secrets. Local forwarding gives you a third. Going live without swapping the secret fails every request.
Sign: it worked in test and every live event fails. Fix: use the secret from the endpoint you are actually receiving from, and confirm it is set in production, not only locally.
3. The endpoint is not subscribed to the event
An endpoint only receives the event types you selected. If you subscribed to checkout.session.completed and your code waits for customer.subscription.created, nothing arrives.
Sign: Stripe’s log shows no attempts at all. Fix: check the endpoint’s event list against what your handler expects.
4. Two events both grant access
This one causes intermittent bugs rather than total failure. checkout.session.completed and customer.subscription.created can both fire for one purchase, in either order. If both grant access, you get double rows, doubled credits or a race that sometimes loses.
Fix: pick one event as the single trigger for granting access and ignore the other. On a recruiting platform we had to strip the subscription-created path out entirely and let the checkout event own it. The race disappeared.
5. Your handler is too slow, or requires a login
Stripe expects a quick 2xx and retries on failure. If your handler does heavy work before responding, it times out and Stripe retries, sometimes producing duplicates.
Worse: if the webhook route sits behind your app’s authentication, Stripe gets a redirect to a login page and every event fails.
Sign: timeouts, 302s or 401s in Stripe’s log. Fix: verify the signature, save the event, respond 2xx, then do the work. Exclude the route from auth middleware.
6. Events arrive twice and your code is not ready
Stripe explicitly does not guarantee exactly-once delivery. Retries happen. If your handler adds credits every time it runs, a retry doubles them.
Fix: store each event id you have processed and ignore ids you have already seen. This is the difference between a webhook that works and one that works reliably.
Test it before you need it
Stripe’s CLI forwards live events to your machine, so you can watch your handler run against real payloads. Trigger each event you subscribe to, then deliberately send the same event twice and confirm nothing doubles.
A webhook that has not been tested twice with the same event is not finished.