Your webhook works in production but not locally

The handful of reasons a webhook handler that is fine in production fails on your development machine, and how to tell which one you have hit.

Updated

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.

Frequently asked questions

Can I test a webhook without exposing my machine to the internet?

You can replay a captured payload against your handler locally, which covers most iteration. But the first capture, and anything involving the provider's own verification request, needs a reachable public URL.