All posts
webhookstestinglocal-devguide

Local Webhook Testing: How to Receive Webhooks on Localhost

Webhooks can't reach localhost directly. How tunnelling tools work, how to pick one, and how to build a local testing workflow that doesn't slow you down.

17 August 20264 min read

You've built a webhook handler and you want to test it against real events before deploying. The problem: your provider sends a POST request to a public URL, and localhost:3000 isn't one. Stripe has no idea your laptop exists.

This is the first wall almost everyone hits building a webhook integration, and the fix is simple once you understand what's actually happening.

Why localhost doesn't work

Your provider's servers live somewhere on the public internet. When an event fires, they make an outbound HTTP request to whatever URL you configured. localhost only resolves to "this machine" on the machine that's running it. Stripe's servers have no route to your laptop, no matter how confidently you paste http://localhost:3000/webhooks into their dashboard.

The fix is a tunnel: a service that gives you a public URL and forwards every request it receives straight to your local port.

Provider → https://abc123.ngrok.io  →  tunnel service  →  localhost:3000

Setting up a tunnel

Install a tunnelling tool

ngrok is the most common choice. Cloudflare Tunnel is a solid free alternative if you already use Cloudflare.

brew install ngrok
# or: npm install -g ngrok

Start your local server

Run your app as normal, on whatever port it listens on.

npm run dev
# Server running on http://localhost:3000

Open the tunnel

Point the tunnel at that port. It prints a public HTTPS URL.

ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000

Configure the provider

Paste the tunnel URL (plus your route, e.g. /webhooks/stripe) into the provider's webhook settings and trigger a test event.

ngrok's web interface at http://127.0.0.1:4040 shows every request that hits your tunnel, including headers and body, before your code even runs. It's often faster to check there than to add logging to your handler.

The URL-changes-every-restart problem

Free ngrok tunnels assign a new random subdomain each time you start one. Restart your dev session and the URL you configured in Stripe's dashboard is dead. You'll see failed test events with no obvious cause until you notice the URL in your terminal no longer matches the one in the provider dashboard.

A few ways around this:

# Paid ngrok plans support a fixed subdomain
ngrok http 3000 --domain=my-app.ngrok-free.app

Cloudflare Tunnel gives you a stable, named tunnel for free if you're willing to do a one-time setup against a domain you control. The tradeoff is a bit more configuration upfront in exchange for never touching the provider's dashboard again.

Testing without a live provider

Sometimes you don't need a real Stripe event, you just need to check that your handler parses the payload and responds correctly. curl gets you there faster than triggering a real event and waiting for it to arrive:

curl -X POST http://localhost:3000/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=1699999999,v1=fake_for_local_testing" \
  -d '{"type": "payment_intent.succeeded", "data": {...}}'

This skips signature verification unless you compute a real HMAC, which is fine for testing your business logic in isolation but won't catch signature bugs. Most providers also offer a CLI that generates properly signed test events; Stripe's CLI (stripe trigger payment_intent.succeeded) and GitHub's gh webhook forward extension both do this and are worth the five minutes it takes to install them.

Replaying instead of re-triggering

Once you have a handler that mostly works, the slow part of iterating is often the provider itself: opening its dashboard, finding a test event, and firing it again just to check a one-line fix. If you're capturing events somewhere durable first (rather than pointing the provider straight at your tunnel), you can skip that round trip and just replay the stored payload against your local endpoint as many times as you need.

Don't leave a tunnel running against a machine with production credentials on it. A public URL forwarding to your laptop is a real attack surface, even if the odds of someone guessing the subdomain are low.

A workflow that scales past day one

Local tunnels are great for the first afternoon of wiring something up. They get annoying once you're testing regularly: URLs expire, you forget which tunnel is pointed where, and every teammate has to repeat the same setup on their own machine.

If you're already receiving events through Webhook Watchtower, local testing gets simpler: point a tunnel destination at your existing source instead of reconfiguring the provider, and replay any past event straight to your machine instead of triggering a new one. See the destinations guide for setting one up.

Frequently asked questions

Can I test webhooks without a public URL?

Not from the real provider. Stripe, GitHub, and Shopify all send requests over the public internet, so your machine needs a reachable address. A tunnelling tool like ngrok or Cloudflare Tunnel gives you one without touching your router or firewall. You can also fake payloads locally with curl if you just want to test your handler logic without a live provider.

Is ngrok safe to use with real webhook secrets?

Yes, for development. Traffic runs over HTTPS to ngrok's servers and back down to your machine, and the tunnel URL is only known to you unless you share it. Don't leave a tunnel open long-term or reuse a dev secret in production, but for day-to-day local testing it's the standard approach and what most API providers recommend.

Why did my tunnel URL stop working?

Free ngrok tunnels generate a new random URL every time you restart, so any webhook configured against the old URL will fail silently until you update it. A paid plan gets you a stable subdomain. Alternatively, use a service that lets you swap the destination URL without reconfiguring the provider, so you only update one place.

Share this post

Webhook Watchtower

Stop debugging webhooks in logs.

Capture every event, replay failures, and get alerted when deliveries fail, without changing your existing setup.

Try it free →