Webhooks are hard to develop against because the sender is on the internet and your handler is not. The fix is to give your machine a public URL, point the webhook at it, and inspect what actually arrives.
1. Get a public URL
Start a tunnel to your local server — see
exposing localhost to the internet for the detail.
You will end up with something like https://u1-a3f9.schemaclient.com.
2. Register the endpoint
Give the provider your tunnel URL plus your handler's path:
https://u1-a3f9.schemaclient.com/webhooks/stripe
In the Stripe dashboard that is Developers → Webhooks → Add endpoint. GitHub puts it under repository Settings → Webhooks. Slack calls it the Request URL.
Some providers send a verification request the moment you save. If yours does, your handler
must respond 200 to it before the endpoint is enabled — and it often arrives before you
have written any handler logic, so a bare 200 stub is worth having first.
3. Trigger an event and read the payload
Do the thing that fires the webhook, then open the Traffic Monitor. Every delivery shows up with its headers and body. This is where the useful information lives:
- The signature header —
Stripe-Signature,X-Hub-Signature-256. - The event type, so you can confirm you subscribed to the right one.
- The delivery ID, which providers use for retries and deduplication.
Reading the real delivery matters more than it sounds. Provider documentation shows an
idealised payload; the actual one often has extra fields, a different Content-Type, or a
nested envelope the docs gloss over.
4. Replay instead of re-triggering
Once a delivery is captured you can replay it against your handler. This is the part that saves real time: you stop creating test charges or opening throwaway pull requests just to exercise one code path, and you get a byte-identical payload every run.
That byte-identical part is what makes signature debugging tractable — you can change your verification code and re-run the exact same request until it passes.
5. Verify the signature correctly
Almost every signature bug is the same bug: the body was parsed before it was verified.
Providers sign the raw bytes of the request body. Most web frameworks helpfully parse JSON into an object before your handler runs. If you then re-serialise that object to check the signature, you get different bytes — key order changed, whitespace vanished — and verification fails even though nothing is wrong.
The fix is ordering:
- Read the raw body as bytes or a string.
- Compute the signature over those exact bytes.
- Compare using a constant-time comparison, not
==. - Only then parse the JSON.
In Express that means express.raw() on the webhook route rather than the global
express.json(). In Django, read request.body before touching anything that parses it. In
Rails, request.raw_post.
Also check what is actually being signed. Some providers sign the body alone; others sign a timestamp concatenated with the body, and reject deliveries older than a few minutes to prevent replay. If your handler works on a fresh delivery but fails on a replayed one, that timestamp tolerance is why.
6. Handle retries properly
Providers retry on any non-2xx response, so your handler must be idempotent. Store the delivery ID and ignore repeats.
Return 200 as soon as you have persisted the event and do the slow work afterwards — most
providers time out in a few seconds and a timeout counts as a failure worth retrying. A
handler that sends an email before responding will eventually send that email several times.
The shape that works:
receive → verify signature → check delivery ID not already seen
→ persist raw event → return 200
→ process asynchronously
7. Test the failure paths
The happy path is the easy half. Before you ship a webhook handler, confirm:
- A bad signature is rejected with a
4xx, not accepted. - A duplicate delivery ID does not double-process.
- An unknown event type is ignored gracefully rather than throwing.
- A malformed body returns a
4xxinstead of a500. - A slow downstream does not delay your
200.
Replay makes all of these testable: capture one real delivery, then modify and resend it.
Lock the payload shape down
Once the payload shape is stable, lock it in with
schema validation so a provider changing a field
does not silently break you. This is a genuine risk — webhook payloads evolve without
warning, and the failure mode is usually a quiet KeyError in a background job rather than
anything visible.
If a webhook works in production but not locally, the cause is usually environmental rather than logical — that specific problem is worked through in when a webhook works in production but not locally.