Documentation menu

Testing webhooks locally

Receive Stripe, GitHub or Slack webhooks on your development machine, inspect the payloads, and replay them without re-triggering the event.

Updated

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:

  1. Read the raw body as bytes or a string.
  2. Compute the signature over those exact bytes.
  3. Compare using a constant-time comparison, not ==.
  4. 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 4xx instead of a 500.
  • 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.

Frequently asked questions

How do I test a webhook without triggering a real event every time?

Capture the first delivery in the traffic monitor, then replay it. Replaying sends the identical payload and headers to your handler as many times as you need, so you can iterate without creating another charge or pull request.

Why does my webhook signature check fail locally?

Signatures are computed over the exact raw request body. If your framework parses the JSON before the signature check runs, the re-serialised body will differ by whitespace or key order and the check fails. Read the raw body first, verify, then parse.

Why did the provider mark my endpoint as failing when my handler worked?

Most providers time out in a few seconds and treat a timeout as a failure. If your handler does slow work — sending email, calling another API — before responding, you will be marked failing and retried even though the work succeeded. Persist the event, return 200, then do the slow part.

Can I test webhooks without a tunnel?

Only partially. You can hand-craft a request that looks like the provider's and send it to your handler, which is useful for unit tests, but you will not catch header differences, signature problems or encoding surprises. Use a tunnel for the real delivery at least once.