Your webhook handler runs at http://localhost:3000/webhooks. Stripe, GitHub, and your
own upstream services have no way to reach it — localhost is a name that only resolves
on your machine, and your laptop has no public address for a provider to POST to. That
wall is the first thing every developer hits when wiring up webhooks, and it shapes the
whole local testing workflow. This guide covers how to give a provider a door to your
machine, how to exercise the handler without waiting on real events, and how to test the
failure paths you would otherwise only discover in production.
Why localhost is unreachable
A webhook is an inbound HTTP request initiated by someone else’s server. For that server to reach yours, yours needs a routable public address and an open path to it. On a laptop you have neither: you sit behind NAT on a home or office router, often behind a corporate firewall, with a private RFC 1918 address that means nothing on the public internet. The provider’s request has nowhere to go. This is not a configuration you forgot — it is the default state of every development machine, and it is a good thing everywhere except here.
There are two honest ways around it: give your machine a temporary public door (a tunnel), or capture events somewhere public and pull them down to your handler. Both are below.
Tunnels and forwarding
A tunnel establishes an outbound connection from your machine to a public relay, then
forwards requests that hit the relay back down that connection to your local port.
Because the connection originates from your side, it traverses NAT and most firewalls
without any inbound rule. The provider delivers to a public URL on the relay; the relay
streams the request to localhost. Generic tunnelling tools do this, and it is the
standard fix for the localhost problem.
The trade-off with a public tunnel is exactly that it is public: anyone with the URL can reach your handler while the tunnel is up. Treat the URL as a short-lived secret, keep signature verification on even locally (below), and close the tunnel when you stop working.
Streaming captured deliveries with HookWatch
HookWatch’s own answer to the localhost problem is hookwatch tunnel. You point a
provider at a HookWatch endpoint — a stable public URL that captures every delivery — and
run the CLI to forward those captured deliveries to a local process:
hookwatch tunnel
--endpoint ep_9f2c81aa
--to http://localhost:3000/webhooks
--server https://api.hookwatch.example
--key hwk_live_xxx The provider now delivers to the HookWatch endpoint, and the CLI streams each captured delivery to your local handler. Two properties fall out of that design. First, the endpoint keeps recording the full request and response for every delivery, so the tunnel doubles as a log of exactly what you received while developing. Second — and this is the part a generic tunnel does not give you — the deliveries are captured, so you can replay an old one to your local handler later, not only forward whatever happens to arrive right now. The CLI forwards the captured bytes; it does not process or transform the event.
Provider test events
Before you send real events, most providers can fire a sample delivery on demand — a
“send test webhook” button in the dashboard, or a CLI trigger. These are the fastest way
to confirm the plumbing end to end: the tunnel is up, your route matches, the handler
returns 200. Use them for the happy path and for iterating on parsing.
Their limitation is that test events are synthetic. They often carry placeholder IDs,
rounded amounts, and every optional field populated — which is precisely the shape real
traffic will not always have. A handler that passes on test events and breaks on the
first guest checkout with a null customer is the standard outcome of testing on
synthetic data alone. Consult the provider’s documentation for how to trigger test
events, and treat them as necessary but not sufficient.
Replaying real captured events
The higher-fidelity approach is to run your local handler against payloads that actually happened. Real events include the awkward cases synthetic ones omit: missing optional fields, unicode in names, event types you did not know existed, the specific ordering that triggered a bug. If you have captured real deliveries, replaying them at a local handler reproduces production behaviour without waiting for production to send it again.
This is where capture pays off. A HookWatch endpoint records the exact payload of every delivery, and replay re-sends that exact captured payload — to your normal destination or to an alternative target URL, such as your machine over a tunnel. Replaying the precise bytes that broke a handler is the difference between reproducing a bug and guessing at it; how to debug failed webhooks walks through using a captured payload to pin down a failure, and replaying failed webhooks safely covers doing it without double-processing.
Signatures still have to verify
Do not disable signature verification for local testing — a handler that only works with verification off is a handler you have not actually tested. Instead, use the provider’s test-mode signing secret for test and tunnelled traffic, and keep the verification code identical to production. Because neither kind of tunnel alters the body, the raw bytes your handler hashes are the bytes the provider signed, and verification behaves exactly as it will in production.
If verification does fail locally, it is one of the usual culprits — the wrong secret (test vs live), a body parser running before you read the raw bytes, or a trailing newline on a pasted secret — not the tunnel. The signature verification guide has the full checklist and working code in three languages.
Keep secrets out of your shell history
Local testing means handling live-adjacent signing secrets, and the easy ways to set them
are the leaky ways. An export on the command line lands the secret in ~/.bash_history and is visible to ps while the process runs. Prefer a git-ignored env file or a prompt
that does not echo:
# Leaky: the secret is now in your shell history and process list.
export WEBHOOK_SECRET=whsec_test_abc123
# Better: keep it in a git-ignored env file, load it into this shell only.
set -a; source .env.local; set +a
# Or read it without echoing, so it never touches history at all:
read -rs -p "Signing secret: " WEBHOOK_SECRET && export WEBHOOK_SECRET Add .env.local to .gitignore before you write a secret into it, not after. For anything
beyond a personal test key, pull secrets from a real secret manager at runtime rather than
committing them anywhere. And rotate any secret that does end up in a shell history or a
screen share — treat it as exposed.
Local HTTPS
Some providers require an https:// callback URL and will not register a plain http one. A public tunnel usually terminates TLS at the relay and presents a valid https URL,
which satisfies that requirement without any local certificate work. If you instead expose
a local server directly, you will need a certificate your provider accepts — a locally
trusted development certificate is fine for your own browser, but a provider validating the
chain may reject a self-signed one. When a provider rejects your callback URL, check its
documentation for the exact TLS requirement rather than guessing.
Read the request you actually received
Half of local webhook debugging is simply seeing the exact request that arrived — headers, signature, and body — rather than assuming its shape. Log the raw body and the relevant headers at the top of your handler while developing, or inspect them in whatever captured the delivery. When a HookWatch endpoint is in the path, the recorded request gives you the headers and body verbatim — the same evidence you would want in production. To send a hand-crafted request at your handler, the webhook tester builds a copyable curl command with your chosen method, headers, and JSON body. Compare a failing delivery against a passing one of the same event type — the diff usually names the bug.
Test the unhappy paths
The reason to test locally is not only to confirm the happy path works — it is to see how your handler behaves when things go wrong, which is exactly what you cannot safely rehearse in production. Deliberately exercise each failure mode:
- Failure responses. Make your handler return
500for a test event and watch what the sender does. Retryable failures — a transport error, a timeout, or a5xx— are what a retrying system acts on. With a HookWatch endpoint that has retries enabled, a5xxtriggers automatic retries on the endpoint’s backoff schedule (by default three total attempts — so two retries, delayed 30 s and 120 s with ±20% jitter), while a4xxis treated as permanent and is not retried. Returning each and watching the outcome is the clearest way to learn the difference. See webhook retry best practices for the policy in detail. - Timeouts. Add a
sleeplonger than the provider’s delivery timeout and confirm your handler is not doing critical work synchronously. This reproduces the single most common production webhook failure on your terms. - Duplicate events. Send the same event twice — replay a captured delivery, or re-run a test trigger — and verify your handler produces the effect exactly once. This is the local test that proves your idempotency actually works before a provider retry proves it does not.
- Retry behaviour. Fail a delivery, then let it succeed on a subsequent attempt, and confirm the eventual success leaves correct state. A handler that half-completes on the failed attempt and completes again on the retry is a bug you want to find at your desk.
A local setup that can reproduce failures, timeouts, and duplicates on demand is worth far more than one that only ever shows you the happy path. Get the tunnel or capture in place, run real payloads through it, keep verification on with a test-mode secret, and spend most of your effort on the paths that break — those are the ones production will find for you otherwise.