How to Test Webhooks Locally Without ngrok
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 four different approaches to receiving webhooks on localhost, with an honest look at where each one shines and where it falls short. If you just want the short answer, jump ahead to our free ngrok alternative page for the side-by-side comparison, 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 four 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 gives a random URL that changes every restart
- • Free tier rate limits can bite you during heavy testing
- • Static domains and most features require a paid plan ($8+/month)
- • Some corporate VPNs and firewalls actively block ngrok
- • Request history is ephemeral — restart the tunnel and it's gone
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 auth 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.
Persistent URLs
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.
MCP server for AI coding assistants
If you're using Claude Code, Cursor, or Windsurf, 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.
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) |
| Persistent URL across restarts | N/A | No (paid only) | Unreliable | Yes (--subdomain) |
| Request inspection | None | Basic (ephemeral) | None | Full (persistent) |
| Request history | None | Session only | None | Persistent |
| Mock responses | None | No | No | Yes (rule-based) |
| Request replay | None | No | No | Yes |
| 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
Download the CLI from hooklistener.com. It's a single binary with no runtime dependencies.
2. Authenticate
hooklistener auth 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 persistent URL that won't change between sessions:
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.
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 →