A webhook handler that works in production and fails locally is almost never a logic bug. It is an environment difference, and there are only a few candidates.
The provider cannot reach you at all
The obvious one. localhost means nothing to Stripe's servers. You need a public URL
that forwards to your machine — see
exposing localhost to the internet.
Symptom: nothing appears in your logs, and the provider's dashboard shows the delivery as failed with a timeout or DNS error.
Your framework is rejecting the Host header
Traffic arrives, and your app returns a 400 before your handler runs. Django checks
ALLOWED_HOSTS; Rails checks config.hosts. Both reject an unfamiliar hostname by
design, and a tunnel hostname is unfamiliar.
Symptom: requests visible in the traffic monitor, 400 responses, nothing in your application logs.
The signature check fails
This is the subtle one. Signatures are computed over the raw request body. If any middleware parses JSON before your verification runs, and you then re-serialise the parsed object to check the signature, the bytes differ — key order, whitespace, unicode escaping — and the check fails every time.
Read the raw body, verify, then parse. Frameworks that eagerly parse bodies usually have an explicit escape hatch for exactly this reason.
Symptom: works in production because the production deployment reads the raw body correctly, fails locally because your dev middleware stack differs.
HTTPS versus HTTP confusion
Providers require HTTPS. A tunnel terminates TLS for you, so your app sees plain HTTP
and may generate http:// URLs or refuse to set secure cookies. If your framework
supports X-Forwarded-Proto, trust it.
You are being retried and did not notice
Providers retry non-2xx responses. If your handler is slow, the provider times out,
marks it failed, and sends the same event again — so you see duplicates and conclude
something is looping. Return 200 as soon as the event is persisted, then do the slow
work.
How to tell which one you hit
Look at where the request stops:
- Not in the traffic monitor — connectivity. The tunnel is down or the URL is wrong.
- In the monitor, 400 with nothing in app logs — host header rejection.
- In the monitor, reaches your handler, 401/403 from your own code — signature.
- In the monitor, same payload repeatedly — retries from a timeout.
That sequence resolves nearly every case. The full walkthrough is in testing webhooks locally.