How to Test Webhooks Locally
Why Stripe, GitHub and other providers can't reach localhost, and how to capture real webhooks on a public URL and forward them to your machine.
Updated:
You're building a webhook handler on http://localhost:3000, but Stripe, GitHub or Telegram need a public HTTPS address to send events to. This guide covers why that is, the usual ways around it, and a two-step workflow: capture and inspect the real request first, then forward it to your local server.
Why providers can't reach localhost
localhost means "this machine". When a provider's server sends a request to http://localhost:3000/webhook, it reaches its own machine, not yours. Your laptop also usually sits behind NAT and a firewall, so it has no address the internet can reach. On top of that, most providers only accept HTTPS endpoints with a valid certificate.
That leaves you three requirements:
- A public hostname.
- HTTPS with a trusted certificate.
- A way to get each request from that hostname to your local port.
Your options
| Approach | How it works | Good for |
|---|---|---|
| Tunnel (ngrok, cloudflared and similar) | A client on your machine keeps a connection open to a public edge server, which proxies traffic to your port | Long-running exposure of a local app |
Provider CLI (e.g. stripe listen) |
The provider streams events to a local listener | A single provider that offers one |
| Webhook Inspector | A public capture URL that stores every request, plus forwarding to localhost via a browser tab or a CLI agent | Seeing the raw request first, then replaying or forwarding it |
A tunnel sends traffic straight to your app, so if your handler crashes or returns the wrong status, the request is gone unless the provider retries it. A capture URL keeps every request, so you can inspect it, fix your code and replay it.
Step 1: Capture the real request
- Open Webhook Inspector. No signup. You get a unique URL like
https://webhook.voronyuk.com/abcd23efgh/stripe, whereabcd23efghis a random 10-character hash andstripeis a name you choose (or a random one). - Paste that URL into the provider's webhook settings. Any sub-path and query after it are captured too, so
https://webhook.voronyuk.com/abcd23efgh/stripe/orders?x=1works. - Trigger an event, or send one yourself:
curl -X POST https://webhook.voronyuk.com/abcd23efgh/stripe/orders?x=1 \
-H "Content-Type: application/json" \
-d '{"event":"order.created","id":42}'
The request shows up live, with no page refresh: method, path, query, headers, body (JSON pretty-printed, form data decoded, binary as base64), size and client IP. By default the URL answers 200 with {"ok":true,"request_id":N}.
This is where you learn what the provider actually sends: the exact headers, the signature format, and whether the body is JSON or form-encoded. You can search across paths, headers and bodies, filter by method, copy any request as curl, or export everything as JSON or HAR.
Step 2: Forward to localhost
Once you know what's coming, send it to your code. The tool offers three forwarding modes in its Forwarding panel.
Browser relay
Keep a tab of the tool open, and it forwards each captured request to a target such as http://localhost:3000. Your local server must allow CORS, because the request is made from the browser. Nothing to install.
CLI agent
The CLI agent works without a browser, reconnects on its own and replays requests it missed while it was offline. Copy the ready-made command, token included, from the Forwarding panel:
WI_TOKEN='<token>' inspector listen https://webhook.voronyuk.com/abcd23efgh/stripe --to localhost:3000
Server forward
For a public target, such as a staging server, the server can forward each request itself. Private and localhost addresses are blocked for security. Limits: up to 60 forwards per minute per URL, a 5-second timeout, and redirects are not followed.
What the provider sees
Forwarding is one-way. Your app receives every request, but the provider always gets the inspector's own response — the default 200 or the custom one you configured — not your app's reply. That's fine for providers that only need a 2xx, such as Stripe, GitHub or Shopify. If a provider needs your app's answer in the HTTP response (for example Slack's url_verification challenge), give that provider your app's own public URL instead.
How paths map
The sub-path and query of the captured request are appended to the target. With target http://localhost:3000:
https://webhook.voronyuk.com/abcd23efgh/stripe/orders?x=1
→ http://localhost:3000/orders?x=1
So if your handler lives at /webhook, register https://webhook.voronyuk.com/abcd23efgh/stripe/webhook with the provider.
Step 3: Fix and replay
When your handler fails, you don't need to trigger a new event. In the webhook request inspector, replay any captured request from the browser (works for localhost targets) or from the server (public URLs only). Change your code, replay it, repeat.
A few practical tips:
- Signature checks need the raw body. Most providers sign the exact bytes they send. Verify against the raw body, not a re-serialized JSON object.
- Make responses fast. Providers time out quickly, sometimes after just a few seconds. Acknowledge with
2xxfirst, then do the heavy work. - Mock responses when needed. For each URL you can set a custom status code (200–599), headers, a body up to 64 KB and a delay up to 30 seconds, which is useful for testing how a provider handles errors and timeouts.
Limits worth knowing
- Up to 10 URLs per session and 1000 stored requests per URL (oldest removed first).
- Bodies are stored up to 1 MB. Each URL accepts up to 500 requests per minute.
- Requests are kept for 72 hours.
- Plain
http://redirects to HTTPS (GET with 301, other methods with 308). - Your session is a cookie, not an account. Use Session link to open the same URLs in another browser. Only the session owner can see captured requests.
For provider-specific steps, see the guides on Stripe webhooks, GitHub webhooks and Telegram bot webhooks.
FAQ
Can I receive webhooks on localhost without ngrok?
Yes. Any setup that gives you a public HTTPS URL and a path to your machine works. With Webhook Inspector, you capture requests on a public URL and forward them to localhost through the browser relay or the CLI agent, with no tunnel to configure.
Why does my local webhook endpoint never receive anything?
The provider can't resolve or reach localhost on your machine. Register a public URL instead, check that the request arrives there, and then forward it locally.
Do I need CORS for the browser relay?
Yes. The relay sends requests from the browser tab to your local server, so the server has to allow cross-origin requests. If you'd rather not change CORS settings, use the CLI agent.
What happens to requests sent while my laptop is off?
They're still captured and kept for 72 hours. The CLI agent replays requests it missed once it reconnects, and you can replay any request manually.
Is the capture URL private?
Only your session can see captured requests. Anyone who knows the URL can send requests to it, so treat it like a secret and always verify provider signatures in your handler.