You set up the webhook in the provider's dashboard, pointed it at http://localhost:3000 and… nothing arrived. It's not a bug: it's networking. Providers deliver webhooks over the internet, and your localhost simply doesn't exist to it. In this guide we compare the four strategies for testing webhooks during development — inspection endpoints, tunnels, official CLIs and forwarding — and the scenario where each one is the right choice.
Why the provider can't see your localhost
When a provider fires a webhook, it acts like any ordinary HTTP client: it resolves the address you registered and opens a connection to it. If that address is localhost (or 127.0.0.1), every machine resolves it to itself — to the provider's server, "localhost" is the provider's own server, not your development machine.
Registering your machine's IP doesn't work either. On most networks you sit behind NAT: the router shares a single public IP across every device and has no idea which one should receive a connection coming from outside. Add firewalls that block inbound connections and dynamic residential IPs that change without notice, and the result is that there is no route from the internet to the process running on your port 3000.
To receive a real delivery you need a public HTTPS URL. The strategies below are, at their core, four different ways of getting one — each with its own trade-offs. (If the concept of a webhook itself is still fuzzy, start with the definitive guide to webhooks and come back here afterwards.)
Strategy 1 — Capture before you code: inspection endpoints
Before writing any handler, it pays to see what the provider actually sends. An inspection endpoint is a temporary URL that accepts any request and displays everything that arrived — method, headers, query string and formatted body — right in the browser, without you running any server. Tools like SafeHook, webhook.site and Beeceptor do this in seconds: you copy the generated URL, register it with the provider, trigger a test event and examine the real delivery.
This step looks skippable, but it prevents a classic mistake: coding against the documentation and finding out in production that the real payload is different. Docs lag behind; the payload that lands on the inspection endpoint doesn't lie. When examining the capture, pay attention to:
- Signature and identification headers — the exact header name (every provider uses its own), the value's format, and whether there's a delivery ID for deduplication.
- The real content type — most providers send
application/json, but some useapplication/x-www-form-urlencodedor even XML. - The event envelope — where the event type, the ID and the actual data live; some providers send the full object, others only a reference for you to query the API.
Strategy 2 — Tunnels: expose your localhost
A tunnel flips the problem around. Instead of waiting for an inbound connection (which NAT blocks), an agent on your machine opens an outbound connection to a public server — and outbound connections cross NAT and firewalls without drama. The public server receives requests on the URL it gave you and dispatches them through the tunnel to your local process. To the webhook provider, you look like a normal server on the internet.
ngrok
ngrok is the best-known tunnel. One command exposes your app's port:
$ ngrok http 3000
Forwarding https://a1b2-203-0-113-7.ngrok-free.app -> http://localhost:3000Register the https://…ngrok-free.app/webhooks URL with the provider and deliveries start landing on your local handler. ngrok also ships a local dashboard at http://127.0.0.1:4040 that logs every request and supports replay — it re-sends the same delivery without you having to trigger the event again on the provider's side. The free plan's limitation: the URL is random and changes on every run, forcing you to re-register the webhook in the provider's dashboard every time you restart the tunnel; a fixed domain is a paid feature.
Cloudflare Tunnel
cloudflared plays the same role, with free "quick tunnels" that don't even require an account:
$ cloudflared tunnel --url http://localhost:3000
https://random-words-here.trycloudflare.comWith a Cloudflare account and your own domain you can create named tunnels with a stable URL — solving the changing-URL problem for free, at the cost of a slightly bigger initial setup.
Other options
- localtunnel —
npx localtunnel --port 3000, no install; simple, though less stable for continuous use. - Tailscale Funnel — excellent if your team already uses Tailscale; exposes a service from your tailnet to the internet with a stable URL.
- VS Code dev tunnels — the editor's built-in port forwarding works for quick tests without extra tooling.
Strategy 3 — Official CLIs: real events without exposing anything
Some providers solve the problem at the root with a CLI that connects to your account and forwards events to localhost over an outbound connection — no public URL, no tunnel, nothing to re-register. The Stripe CLI is the canonical example:
$ stripe listen --forward-to localhost:3000/webhooks
> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx
# in another terminal, trigger a test event:
$ stripe trigger payment_intent.succeededNote the important detail: stripe listen prints a local signing secret. Forwarded deliveries are signed with it, which lets you test signature verification for real during development. GitHub has an equivalent via an official CLI extension:
$ gh webhook forward --repo=your-org/your-repo --events=push \
--url=http://localhost:3000/webhooksWhen a provider offers a CLI like this, it's usually the best day-to-day option: real events, verifiable signatures, zero exposure of your machine and no URL to re-register. The limitation is obvious — not every provider has one. Stripe and GitHub do; most Latin American payment gateways, for instance, don't.
Strategy 4 — Forwarding from a stable public URL
The previous strategies share one point of friction: the URL registered with the provider points at something ephemeral (a tunnel that dies, a temporary capture). The fourth approach inverts that: you register a persistent public URL with the provider — a capture/relay endpoint that is yours and never changes — and it is from there that events get forwarded to wherever you need them: your localhost during development, a staging environment, or both.
This is especially useful for teams: everyone shares the same endpoint registered with the provider, and each person pulls events to their own machine while working on the integration. Some inspection tools offer this forwarding built in; you can also build a minimal relay of your own on a VPS — a twenty-line handler that receives, logs and re-sends over HTTP to a configurable destination.
The caveat: the relay becomes infrastructure. If it goes down, deliveries start failing and pile up in the provider's retries. For development that's tolerable; if the same piece ends up in production, treat it with production-grade rigor.
Simulating deliveries with curl
Nothing stops you from impersonating the provider on your own machine. Capture a real payload (with an inspection endpoint, for example), save it as a fixture and fire it at the local handler:
$ curl -X POST http://localhost:3000/webhooks \
-H "Content-Type: application/json" \
-H "X-Event-Id: evt_test_001" \
-d '{"type":"payment.approved","data":{"payment_id":"pay_123","amount":14990}}'It's the fastest way to test routing, parsing and business rules — and the only one that runs offline and fits in an automated CI test. The limitation: curl doesn't know how to sign the request the way the provider does. To exercise signature verification you either compute the HMAC of the body with a test secret inside the script, or use a real delivery via tunnel/CLI. The full signature mechanism is broken down in Webhook security: HMAC signatures and best practices.
Which strategy should you use?
There is no single winner — there's the right tool for each moment of the workflow:
| Scenario | Best strategy | Why |
|---|---|---|
| Exploring the payload before coding | Inspection endpoint | Zero setup; shows real headers and body without writing code |
| Developing the handler with real events | Official CLI (if one exists) or tunnel | Real deliveries landing on your local process, with replay |
| Testing signature verification | Official CLI or tunnel | The only paths that produce genuinely signed deliveries |
| Stable URL for a team or staging | Persistent forwarding | Register once with the provider; each destination pulls the events |
| Automated tests / CI | curl + fixtures of real payloads | Reproducible, offline and fast; sign the body yourself if needed |
Summary
- Localhost is unreachable from the internet because of NAT, firewalls and dynamic IPs — testing webhooks requires a public HTTPS URL, and every strategy is a way of getting one.
- Start with an inspection endpoint to see the real payload before writing the handler.
- For development, prefer the provider's official CLI when it exists; failing that, a tunnel (ngrok, cloudflared) does the job — remembering that the free URL changes every session.
- Forwarding from a persistent URL eliminates re-registration and works well for teams; curl with fixtures covers automated tests.
- Whatever the path: use sandbox data in third-party tools and verify signatures even in dev.