How to Test Webhooks Locally Without ngrok
How do I forward webhooks to localhost from the CLI?
Install the Hooklistener CLI, sign in once, and run hooklistener tunnel --port 3000. It prints a public HTTPS URL on hook.events that forwards every request to your local server and stores a copy you can inspect and replay (Pro). Paste that URL into the webhook settings of Stripe, GitHub or any other provider.
npm i -g hooklistener # or: brew tap hooklistener/tap && brew install hooklistener
hooklistener login # one-time browser sign-in
hooklistener tunnel --port 3000
# Public URL: https://abc123.hook.events → http://localhost:3000On the Free plan the tunnel gets a random subdomain each time it starts; paid plans can keep one with --subdomain. Testing Stripe specifically? See how to test Stripe webhooks locally, and the CLI tunnel page for the full setup. Weighing other tunnels? See ngrok alternatives compared.
You're building a webhook handler. Stripe, GitHub, Shopify, Twilio — pick your provider. You write the endpoint, start your local server on port 3000, and then remember: that third-party service needs to send an HTTP request to your machine. Your machine that sits behind a NAT, a firewall, and probably your ISP's CGNAT for good measure.
This article walks through five different approaches to receiving webhooks on localhost, with an honest look at where each one shines and where it falls short. For the side-by-side tool comparison, see our free ngrok alternative page, or grab a free webhook tester URL to start inspecting deliveries right now.
Why Can't Webhooks Just Reach Localhost?
When Stripe sends a webhook to https://your-app.com/webhooks/stripe, it makes a regular outbound HTTP request. Your production server has a public IP, so that works. But your laptop doesn't. It's behind a router doing network address translation, and there's no path from Stripe's servers to localhost:3000.
You need something in between — a publicly reachable endpoint that accepts the request and gets it to your machine somehow. The five approaches below are different ways of building that bridge.
Approach 1: Port Forwarding on Your Router
The old-school option. You log into your router's admin panel, set up a port forwarding rule that sends traffic on port 443 (or whatever) to your machine's local IP, and give the webhook provider your public IP address.
When it works
- • You have a static IP from your ISP
- • You control the router (not a corporate network)
- • You're the only developer who needs this
When it doesn't
- • Most ISPs assign dynamic IPs that change without warning
- • Corporate and university networks won't let you touch the router
- • You're opening a port on your home network — a security surface you probably don't want
- • No HTTPS unless you set up certificates yourself
- • Every teammate needs their own setup
Approach 2: ngrok
ngrok is the tool most developers reach for first, and that's earned. It was one of the earliest polished solutions to this problem, and it does the job well. Install it, authenticate, and run:
ngrok http 3000You get a public URL like https://a1b2c3d4.ngrok-free.app that tunnels traffic to your local port. The terminal shows a live stream of requests as they come in.
What ngrok does well
- • Dead simple to get started
- • Mature, well-documented, battle-tested
- • Web inspector at localhost:4040 for viewing requests
- • Supports TCP tunnels, TLS termination, custom domains (paid)
- • Large ecosystem of integrations
Where it gets tricky
- • Free tier caps you at 20,000 HTTP requests a month and shows an interstitial page on HTTP endpoints
- • One static dev domain on Free; more domains and features need a paid plan (Hobbyist is $10/month)
- • Some corporate VPNs and firewalls actively block ngrok
- • Traffic Inspector keeps 24 hours on Free, and replay needs the opt-in Full Capture mode
Approach 3: localtunnel
localtunnel is an open-source option that takes a different approach. It's an npm package, and you can be running in seconds:
npx localtunnel --port 3000You can request a specific subdomain too:
npx localtunnel --port 3000 --subdomain my-projectWhat localtunnel does well
- • Free and open source
- • No account required
- • Simple enough for quick throwaway tunnels
- • Subdomain support for semi-stable URLs
Where it gets tricky
- • The public server (loca.lt) can be unreliable
- • Confirmation page breaks webhook delivery from automated services
- • No built-in request inspection
- • Self-hosting fixes reliability but adds infrastructure
- • Limited support and no guarantee of long-term availability
Approach 4: Hooklistener
Hooklistener takes a different angle on this problem. Instead of being purely a tunnel, it's a webhook debugging platform that happens to include tunneling. The distinction matters because of what sits on top: request capture, inspection, replay, and mock responses.
hooklistener login
hooklistener tunnel --port 3000You get a public URL like https://abc123.hook.events forwarding to your local server. So far, similar to the other tools. Here's where it diverges.
Full request capture
Every request that hits your tunnel is stored with complete headers, body, query parameters, timing data, and TLS info. This isn't just a log line — it's the full HTTP exchange, inspectable in a web dashboard. If you don't need the tunnel at all and just want a public endpoint to receive and inspect inbound payloads, the webhook inbox does that without the CLI.
Static subdomain (Pro)
With the Pro static tunnel, use --subdomain to get a stable URL that survives restarts. hooklistener tunnel --port 3000 --subdomain my-project gives you my-project.hook.events, every time.
Request history that persists
Unlike ephemeral tunnel inspectors, your captured requests stay in the dashboard. Close the tunnel, reopen it tomorrow, and your history from yesterday is still there.
Mock response rules
Configure your endpoint to return specific responses based on request content. Testing error handling? Set up a rule that returns a 500 when the body contains "type": "payment_intent.failed". No changes to your code needed.
Request forwarding and replay
Captured a tricky edge-case webhook? Replay it against your local server as many times as you need while debugging. Replay is on Pro and above.
MCP server for AI coding assistants
If you're using Claude Code, Cursor, or Codex, Hooklistener has an MCP server that lets your AI assistant create endpoints, inspect payloads, and wait for incoming requests programmatically.
Where it's limited
- • Focused on HTTP webhooks. If you need raw TCP tunnels or non-HTTP protocols, ngrok has broader protocol support.
- • It's a newer product with a smaller community than ngrok.
- • Advanced features require a paid plan, though there's a free tier for getting started.
Approach 5: provider CLIs (stripe listen)
The fifth approach skips the public URL entirely. Some providers ship a CLI that holds a direct connection to their own API and hands events to your local port. The best-known one is the Stripe CLI's listen command:
stripe listen --forward-to http://localhost:3000/webhooks/stripe
# Ready! Your webhook signing secret is whsec_...
# in a second terminal
stripe trigger payment_intent.succeededIt prints a whsec_ signing secret for your handler to verify against, can filter with --events, and needs no tunnel or open port. (Stripe's CLI reference, checked November 2026.)
The limit
- • It only forwards that provider's events. A Stripe listener does nothing for GitHub, Shopify or Twilio, so a project with several senders still needs a public URL.
For a Stripe-only project it is often the fastest path; the Stripe webhooks guide compares it with a Hooklistener tunnel.
Comparison Table
| Feature | Port Forwarding | ngrok (Free) | localtunnel | Hooklistener |
|---|---|---|---|---|
| Setup difficulty | High | Low | Low | Low |
| Public HTTPS URL | No (manual cert) | Yes (random) | Yes (random or subdomain) | Yes (random or subdomain) |
| Static subdomain across restarts | N/A | Yes (1 static dev domain) | Unreliable | Static subdomain (Pro) |
| Request inspection | None | Traffic Inspector | None | Full (stored) |
| Request history | None | 24 hours (Free) | None | 1 to 90 days, by plan |
| Mock responses | None | No | No | Yes (rule-based) |
| Request replay | None | Opt-in (Full Capture) | No | Yes (Pro) |
| Reliability | Depends on ISP | High | Variable | High |
| VPN-friendly | Yes | Sometimes blocked | Sometimes blocked | Yes |
| AI assistant integration | None | None | None | MCP server |
| Free tier | Free | Limited | Free | Available |
| Protocol support | Any | HTTP, TCP, TLS | HTTP | HTTP |
Getting Started With Hooklistener (Step by Step)
If you want to try the Hooklistener approach, here's the full walkthrough. This assumes you have a local web server running on some port.
1. Install the CLI
Install it with npm or Homebrew:
npm i -g hooklistener
# or: brew tap hooklistener/tap && brew install hooklistener2. Authenticate
hooklistener loginThis starts a device code flow. You'll see a code in your terminal and your browser will open. Paste the code, confirm, and you're authenticated. No passwords stored locally.
3. Start a tunnel
For a quick, temporary tunnel:
hooklistener tunnel --port 3000Tunnel connected!
Public URL: https://abc123.hook.events
Forwarding to: http://localhost:3000
Waiting for requests...For a static subdomain that won't change between sessions (Pro static tunnel):
hooklistener tunnel --port 3000 --subdomain my-projectNow https://my-project.hook.events will always route to your local port 3000 when the tunnel is running.
4. Point your webhook provider at the URL
In your provider's dashboard (Stripe, GitHub, Shopify, wherever), set the webhook URL to your tunnel URL. For example, if your local webhook handler lives at /webhooks/stripe:
https://my-project.hook.events/webhooks/stripe5. Trigger a test event
Most providers have a “send test webhook” button. Hit it. You'll see the request appear in your terminal in real time, and it will also show up in the Hooklistener web dashboard with full details.
6. Inspect the request
Open the Hooklistener dashboard to see the captured request. You'll find:
- • The complete request headers
- • The parsed request body
- • Query parameters
- • Timing information
- • TLS details
7. Test a failure scenario with mock responses
Say you want to see what happens when your endpoint returns a 500 to Stripe. In the Hooklistener dashboard, create a mock response rule on your endpoint:
- Condition: Request body contains
"type": "invoice.payment_failed" - Response: Status 500, body
{"error": "internal server error"}
Now trigger that event type from Stripe. Stripe gets the 500, starts its retry logic, and you can observe the retry behavior (headers, timing, backoff) in Hooklistener's request history without writing any mock code.
8. Replay a request
Found a payload that exposes a bug? Fix your code, then replay that exact request from the dashboard. No need to go back to the provider and trigger a new event. Replay is on Pro and above.
Bonus: Test with curl
You can also send test requests directly to verify your tunnel is working:
curl -X POST https://my-project.hook.events/webhooks/test \
-H "Content-Type: application/json" \
-d '{"event": "test", "data": {"id": "123"}}'This should hit your local server and appear in the Hooklistener dashboard simultaneously.
Which Tool Should You Actually Use?
Stick with ngrok if
you're already on a paid plan that covers your needs, you need TCP or non-HTTP tunnels, or you rely on ngrok-specific integrations in your infrastructure.
Use localtunnel if
you need a quick, free, no-signup tunnel for a five-minute demo and don't care about reliability.
Try Hooklistener if
you spend real time debugging webhook payloads, you want request history that survives tunnel restarts, you're tired of re-pasting URLs into provider dashboards, or you want your AI coding assistant to interact with webhook data directly.
Use port forwarding if
you have a very specific network setup and you know what you're doing. (You probably don't want to do this.)
Try Hooklistener Free
The real test is whether your current approach causes you friction. If your tunnel drops, your URLs change, you can't inspect past requests, or you're losing time context-switching between dashboards — that's a sign your tooling isn't keeping up with your workflow.
Start Testing Webhooks Locally Free