Back to Blog
guides20 min readApril 11, 2026

How to Forward Webhooks to Localhost Without ngrok

Most "how to forward webhooks to localhost" guides are product pitches with a thin tutorial wrapper. This is the architecture-first version: what tunnels, provider CLIs, and capture-and-forward services actually do, where each one breaks, and how to choose without getting burned.

A
Founder, WebhookWhisper · April 11, 2026

Most "how to forward webhooks to localhost" guides are product pitches with a thin tutorial wrapper. This isn't one of those. I run WebhookWhisper, so yes, I have a horse in this race. But I've also been the developer at 2am with a Stripe webhook that won't fire, an ngrok URL that rotated overnight, and a Slack channel full of teammates asking why their staging environment is broken. That part doesn't change based on which tool you pick.

What I want to do here is explain what's actually happening on the wire when you forward a webhook, where every approach quietly breaks, and which trade-offs are worth which pain. Then concrete tool walkthroughs. Including ngrok. Including the Stripe CLI. Including Hookdeck. Including us. As honestly as I can.

What "forwarding webhooks to localhost" actually means

The phrase gets used loosely. There are at least three different mechanisms hiding behind it:

1. Tunnels. A tunnel makes your local machine reachable from the public internet. ngrok, localtunnel, Cloudflare Tunnel, Tailscale Funnel, localhost.run. The provider POSTs the webhook to a public URL; the tunnel pipes those bytes to your local server. While the tunnel is open, your laptop is effectively a public web server. The moment your laptop closes, sleeps, or your tunnel agent crashes, the URL points at nothing.

2. Provider CLIs. Stripe CLI, GitHub's smee.io, Twilio CLI. These don't open a tunnel; they open an outbound persistent connection to the provider's own infrastructure, and the provider re-emits the events down that connection. stripe listen --forward-to localhost:3000 connects out to Stripe's edge, not in to your laptop. That's why no firewall change is needed.

3. Capture-and-forward services. Hookdeck, WebhookWhisper, Webhookify, WebhookRelay. The provider POSTs to our public HTTPS endpoint. We store the event durably, then forward it to a target URL of your choice — including your localhost via an outbound connection your CLI or extension opens. If the target is down, we keep the event and retry. If the target is your laptop and your laptop is closed, the event sits in our queue until you wake up.

These three are not interchangeable. They have different failure modes, different threat models, different operational stories. Most blog posts collapse all three into "expose localhost to a webhook" and that's where the confusion starts.

The three things that actually go wrong

Before tools, the failure modes. If you've been doing this for any length of time you've hit at least two of these.

1. URL drift

You ran ngrok http 3000. It gave you https://e3a7-2401-4900-xxxx.ngrok-free.app. You pasted that into your Stripe dashboard. It worked for an hour. Then your laptop slept, the tunnel died, you ran ngrok again, you got https://e3a8-..., and Stripe is still firing at e3a7-... which now returns 404 from ngrok's edge.

You go back to the Stripe dashboard, paste the new URL, repeat. Then you switch to a paid plan to get a stable subdomain. That helps until you're testing GitHub and Shopify too — three dashboards to update every restart.

This is the single most common pain in the entire space. The fix is either (a) pay for stable subdomains on every tunnel tool, (b) use a provider CLI which doesn't have URLs in the first place, or (c) use a capture-and-forward service where the public URL is a static record in someone else's database.

2. Event loss

Your laptop is closed. A real Stripe payment succeeds. The payment_intent.succeeded webhook fires. ngrok's edge says "no tunnel for this URL" and returns an error. Stripe sees the error and starts its retry schedule — 5 minutes, 30 minutes, 2 hours, etc. You wake up four hours later, run ngrok again, and the previous events are gone because the provider's retry window already ate them or because ngrok never queued anything.

This is the part most "ngrok alternatives" lists don't take seriously. The retry behavior of every major provider is documented (Stripe gives up after 3 days, GitHub after 8 attempts in 4 hours, Shopify after 8 attempts in 4 hours, Twilio after 11 attempts in 24 hours — see also our deep dive on provider retry behavior for how the schedules differ), but in practice events get lost because the developer's machine wasn't reachable when the provider gave up. The error class on the wire is usually connection refused or DNS resolution failure — both unambiguous from the provider's perspective.

Capture-and-forward services solve this by separating "received from provider" from "delivered to your local handler." The first is durable; the second is lossy and retried.

3. Signature breakage

This one is sneaky and it's where I personally got bitten the hardest while building WebhookWhisper. Stripe signs the exact bytes of the request body — including whitespace, key ordering, trailing newlines. The Stripe-Signature header is HMAC-SHA256 over <timestamp>.<raw_body>. Verification recomputes the HMAC and compares. If the body changes by even one byte between Stripe and your verifier, the signature fails.

Most tunnel tools are transparent — they pipe bytes. But some HTTP middleware in your local stack will parse the JSON body and re-serialize it, changing whitespace. express.json() does this by default. The fix is express.raw({ type: 'application/json' }) on the webhook route, before any json parser. In FastAPI, read await request.body(). In Rails, request.body.read. Forgetting this is, by my estimate from looking at GitHub issues over the years, the most common Stripe webhook bug in the world.

If the forwarder you're using touches the body in transit (some forwarders deserialize and re-serialize for transformations), signatures fail before your code even runs. The honest test for any forwarding tool is: does it deliver the exact bytes the provider sent? The answer is yes for ngrok, yes for Stripe CLI, yes for WebhookWhisper, and you should ask the question of any tool you trust before pointing production-mode events at it.

Approach 1 — Tunnels

Tools: ngrok, localtunnel, Cloudflare Tunnel, Tailscale Funnel, localhost.run, smee.io (sort of — see provider CLIs).

How they work: a small agent on your machine opens an outbound connection to the tunnel provider's edge servers. The provider gives you a public hostname. Inbound HTTP to that hostname gets piped through the tunnel back to your local port. The tunnel is bidirectional but only stays alive while your agent is running.

Setup looks like this for ngrok:

# Install ngrok and authenticate
brew install ngrok/ngrok/ngrok
ngrok config add-authtoken YOUR_TOKEN

# Forward localhost:3000 to a public URL
ngrok http 3000

# Output:
# Forwarding  https://e3a7-2401-4900-1234.ngrok-free.app -> http://localhost:3000

You paste that public URL into your Stripe dashboard, fire a test event from Stripe, and your local server gets the request.

When tunnels are right:

  • You're testing locally for a few hours, then you're done.
  • You're integrating one or two providers and don't mind updating dashboards on URL rotation.
  • You need full bidirectional access (e.g. you're also calling back from your local machine to the provider via authenticated APIs that need a real reachable origin).
  • You're already paying for ngrok or have free Cloudflare Tunnel set up — sunk cost.

When tunnels break:

  • Your URL rotates and you've already pasted the old one into 5 provider dashboards. Cost: 10-30 minutes of re-pasting, every time.
  • Your laptop sleeps. You lose every event the provider couldn't deliver during that window. Stripe will retry; your boss's demo for which you needed the lifecycle event in dev will not.
  • Your team can't share the URL — it's tied to your laptop.
  • Your corporate firewall blocks tunnel hostnames (this happens more than you'd think).
  • You're integrating with a provider that requires the URL to be the same one in their dashboard for HMAC purposes. ngrok URLs that include the agent's IP don't always survive certificate pinning on the provider side.

Honest take: ngrok is the right answer when you're solo, you're short-lived, and you don't mind babysitting. It's also the only tool in this space with mature documentation, a paid tier worth paying for, and broad enough adoption that every framework's "test webhooks" doc mentions it. If you're going to pay for one tool in this category, ngrok is reasonable.

Approach 2 — Provider CLIs

Tools: Stripe CLI, GitHub smee.io + the smee-client, Twilio CLI.

How they work: instead of opening a tunnel in to your laptop, your CLI opens a persistent connection out to the provider's own infrastructure. When a webhook fires, the provider re-emits it down your already-open connection. No public URL involved. No firewall change needed. The CLI authenticates as you, and the provider trusts the connection because it's authenticated.

For Stripe:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login (browser auth)
stripe login

# Forward all events to your local handler
stripe listen --forward-to localhost:3000/webhooks/stripe

# Output:
# > Ready! Your webhook signing secret is whsec_abc...

Note that the secret it prints out is not your dashboard endpoint's secret. It's an ephemeral signing secret for this CLI session. Configure your local code to use this ephemeral secret in dev, not the one from your dashboard. This trips up developers constantly — see Stripe's own troubleshooting docs.

For GitHub, the equivalent is smee.io:

npm install --global smee-client

# Get a webhook proxy URL from https://smee.io/new
smee --url https://smee.io/abc123 --target http://localhost:3000/webhook

The proxy URL is public, but it's hosted by smee, not your laptop. Events go to smee, smee pushes them down your CLI connection.

When provider CLIs are right:

  • You're integrating one provider — typically Stripe or GitHub — and want the official, supported, signature-aware tool.
  • You hate the idea of a third-party tool sitting between you and the provider.
  • You want to use trigger commands like stripe trigger payment_intent.succeeded for instant testing without going through the whole payment flow.

When they break:

  • You have multiple providers. Now you have multiple CLIs running, each in its own terminal tab, each consuming RAM, each silently exiting when your terminal session ends.
  • Your team needs a shared dev URL — provider CLIs are single-developer.
  • You want to inspect events that happened yesterday — most provider CLIs don't store anything.
  • You want events to arrive even when your CLI isn't running — they don't.

Honest take: if you're solo and Stripe-only, just use Stripe CLI. Don't overthink it. The day you add GitHub or Shopify or a custom provider, the calculus changes — that's when you start wanting capture-and-forward.

Approach 3 — Capture-and-forward services

Tools: Hookdeck, WebhookWhisper, Svix Play, Webhookify, WebhookRelay, the older crop (RequestBin folded into Pipedream).

How they work: you get a permanent public HTTPS URL, owned by the service. The provider POSTs there. The service stores the event durably (seconds-to-minutes guarantee). Then the service either (a) forwards via an outbound connection your CLI/agent opens, or (b) makes an outbound HTTP POST to a target URL you configure.

The conceptual difference from a tunnel is that the service holds the event. Your laptop being asleep doesn't lose anything. Your localhost being broken doesn't lose anything. The service retries with exponential backoff, and you can replay any event by hand later.

For WebhookWhisper specifically (since I built it and can describe it accurately), the flow is:

  1. You get a free permanent endpoint URL like https://webhookwhisper.com/hook/abc123. No CLI install. No account required to start, though signing up keeps the URL forever.
  2. You paste that URL into Stripe (or GitHub, Shopify, Twilio, etc).
  3. You configure a forwarding rule pointing at http://localhost:3000/webhooks/stripe. Done in the dashboard.
  4. Provider fires an event. We receive it, store it (14 days on Starter, 30 days on Pro, 7 on Free), and POST it to your localhost.
  5. If localhost is down or returns 5xx, we retry with exponential backoff. The dashboard shows you every attempt.

The forwarding mechanism for localhost works because we make an outbound HTTP request from our infrastructure to your localhost, which only works if your machine is reachable from the public internet — which is the original problem. So in practice "forward to localhost" means one of two things:

  • Forward to a tunnel URL you set up locally (we have a small browser extension or wrapper that opens an outbound connection from your machine — same conceptual mechanism as a provider CLI).
  • Forward to your dev/staging server on a real hostname, and use the same WebhookWhisper URL across local and staging by configuring different forwarding rules per environment.

I'll be honest about the trade-off: if you want zero-install, zero-friction, "give me a URL right now" — a tunnel is faster to set up. WebhookWhisper takes 30 seconds longer to start because you're configuring a forwarding rule. We trade that 30 seconds for: persistence, retry, multi-provider routing, team sharing, replay, and the URL doesn't rotate.

When capture-and-forward is right:

  • Multiple providers. One URL pattern across Stripe, GitHub, Shopify, custom.
  • Team-shared dev environment. Multiple developers, same set of URLs.
  • You want to replay yesterday's failed delivery against a fix.
  • You want forwarding to retry when localhost is down (laptop closed, server crashed, deploying).
  • You don't want to install yet another CLI.

When it's not right:

  • You need ultra-low-latency local testing (every hop adds latency — capture-and-forward adds at least one hop versus a direct tunnel).
  • You're on dial-up internet from 2003 and adding any hop is painful (this is mostly a joke, but ultra-constrained network environments do exist).
  • You're testing something that genuinely needs the provider to talk to your laptop directly — for example, IP-allowlisted webhooks where the provider only accepts replies from a specific origin IP.

Honest take on competitors in this category: Hookdeck is excellent for production-grade webhook infrastructure (we'll get there one day; today we're not). Their free tier is generous, their docs are some of the best in the industry, and their reliability features (idempotency, transformations, dead-letter queues) are genuinely beyond what most teams need on day one. WebhookWhisper is better-suited if you want a faster onramp, a free tier that doesn't ask for a credit card, and a mental model closer to "tunnel but persistent." Different points on the curve.

The thing nobody talks about — signature verification through forwarding

Every blog post on this topic stops at "and now you can test your webhook." Almost none address the signature problem.

Here's the issue. Every major webhook provider signs the request body with HMAC. The signature is in a header. To verify, your code recomputes the HMAC over the exact bytes you received and compares.

If you're tunnelling, you're fine — bytes pass through unchanged. If you're using a provider CLI, you're fine — the CLI is signature-aware. If you're using a capture-and-forward service, ask the question: does it deliver the same body bytes the provider sent?

It should. Most do. But some don't — the ones that allow body transformations (replacing fields, normalizing JSON, etc.) by definition rewrite the bytes, which breaks the signature. The right behavior for a forwarder that supports transformations is to compute a new signature with its own secret and let the receiver opt into either provider verification (against the original body) or service verification (against the transformed body, with the service's secret).

I shipped HMAC signing for WebhookWhisper outbound deliveries in May 2026 (ADR-015 in our public design notes if you want the gory details). The format is Stripe-compatible: X-WebhookWhisper-Signature: t=<unix>,v1=<hex>, HMAC-SHA256 over <timestamp>.<raw_body>. We expose verification recipes in 6 languages at /docs/webhook-signing. We also kept the original provider headers intact — Stripe-Signature, X-Hub-Signature-256, X-Shopify-Hmac-Sha256 — so you can verify against the provider directly if you prefer not to trust us.

This is the part of the architecture I was most paranoid about. I built a separate playground specifically for debugging signature mismatches: webhook-signature-playground. Paste your payload and secret, see what signature your code should compute. It runs entirely in your browser via the Web Crypto API; the secret never touches our servers.

Choosing — a decision tree

Less elegant than I'd like, but here's the honest decision tree based on what you're actually trying to do.

Solo developer, integrating only Stripe, short-term: Stripe CLI. stripe listen --forward-to localhost:3000. Don't overthink it. You can switch later.

Solo developer, integrating only GitHub, short-term: smee.io. Same logic.

Solo developer, multiple providers, short-term: tunnel (ngrok if you want polished, Cloudflare Tunnel if you're already on Cloudflare and want free). Pay for stable subdomains so URLs don't rotate.

Solo developer, multiple providers, ongoing: capture-and-forward. WebhookWhisper if you want a free tier that's actually free. Hookdeck if you're already paying for an enterprise webhook gateway. Stripe CLI alongside for Stripe-specific trigger commands; not in conflict.

Team, shared dev environment: capture-and-forward. The team needs a URL pattern they all share, with different per-environment forwarding rules. Tunnels and provider CLIs don't compose at this level.

Production webhook ingest at high volume: Hookdeck or Svix. WebhookWhisper today is targeted at developer-tier use cases (single VPS, small team, clear scaling roadmap but not yet at "Walmart's checkout webhook ingest" scale). If you're at that scale, the right call is the tier above us.

I write this as someone whose product is on this list, so take it with the appropriate salt — but I genuinely don't think there's one tool here that's right for every case. The question is what you're optimizing for: setup speed (tunnel), official supportedness (provider CLI), persistence and team-share (capture-and-forward), or production-grade reliability (enterprise gateway).

Real failure modes — a table you can actually use

From three years of building, debugging, and reading webhook-related GitHub issues. If you're hitting one of these, the cause is in column two and the fix is in column three.

Symptom Likely cause Fix
Signature verification fails with "no signatures found matching the expected signature" Body parsed and re-serialized before verification (express.json before raw) Use express.raw({type:'application/json'}) on the webhook route, before any json parser. Read raw bytes in FastAPI / Rails / Django equivalents.
Signature verification fails with "timestamp outside tolerance" Server clock drift, OR you're testing with stored payloads from earlier Run timedatectl (Linux) or sync clocks. For stored test payloads, generate a fresh signature against current timestamp using a playground.
Webhooks work in dev but not in production Test-mode and live-mode have different signing secrets. Stripe specifically warns about this. Verify the secret your code is loading by logging the first 6 chars (whsec_xxxxxx) — never log the full value.
Stripe events fire on the test dashboard but never reach localhost Tunnel URL rotated and Stripe is firing at the old URL Update the dashboard, or switch to Stripe CLI which doesn't have URL drift.
Webhooks lost overnight Tunnel agent died or laptop slept; provider's retry window expired Switch to capture-and-forward so events are stored regardless of laptop state.
5xx errors from your handler trigger forever-retries from the provider Returning 500 instead of 2xx, or processing slowly enough to time out Return 200 immediately, queue work asynchronously. Webhook handlers should be 100ms-class, not 30s-class.
Same event processed twice (double-charged customer, duplicate Slack message) Provider retried because your handler timed out; you don't have idempotency Store processed event IDs (Stripe gives you evt_*, GitHub gives you delivery IDs); reject duplicates before side effects.
Shopify HMAC always fails even when secret is right Shopify uses base64, not hex. Most other providers use hex. digest('base64') instead of digest('hex'). One-character fix, hours of pain.

The forwarding loop trap (a real production story)

While testing forwarding rules in production for WebhookWhisper, I once accidentally pointed a forwarding rule at our own endpoint. One inbound webhook turned into 16 events in 8 seconds before I realized what was happening — every forwarded delivery triggered another inbound which triggered another forward. I deleted the rule, the cycle broke, and I sat there staring at the queue thinking about how easily this could have been worse.

That incident is why our forwarding-rules engine now refuses to accept rules whose target hostname matches our own (any variation, including the origin IP). It's also why we stamp every outbound delivery with an X-WebhookWhisper-Event-Id trace header that the receive layer refuses on inbound — so even if someone bypassed the host check, the cycle would break at the second hop. Two-layer defense.

If you're building your own forwarder, this is the kind of bug that doesn't show up until production. The day you support forwarding to user-supplied URLs, the day someone (yourself included) types the wrong URL into the dashboard, you have a queue-amplification incident. Defend at create-time and runtime. Both.

Frequently asked questions

Can I forward webhooks to localhost without installing anything?

Yes — capture-and-forward services like WebhookWhisper give you a permanent public URL plus dashboard-configured forwarding rules. The forwarding from public URL to your localhost still requires your laptop to be reachable somehow, which in practice means either you run a small forwarder agent (browser tab, extension, or CLI) or you forward to a dev server on a real hostname instead of true localhost. The truly "no install" path forwards to your dev/staging server, not literal localhost — but the public URL stays the same across local and staging.

Why does my webhook fail signature verification when I forward through a service?

Either the service modified the body in transit (rare, but happens with tools that support body transformations), or your code is parsing and re-serializing the body before verification. The second is far more common. Use raw body access in your framework. The signature is computed over bytes, not over the parsed object.

Is the Stripe CLI better than ngrok or Hookdeck for Stripe webhooks?

For solo, Stripe-only, short-term development, yes — it's official, signature-aware, and gives you trigger commands for instant testing. The moment you add a second provider or want to share the setup with a teammate, it stops being the right answer. Use it alongside other tools, not as a wholesale replacement.

How do I test webhooks without my laptop being on?

You can't, unless the events are received by something that isn't your laptop. That's the whole point of capture-and-forward services — events go to a public URL we own, get stored, and get delivered (or retried) when your local handler comes back online. Tunnels can't do this; the tunnel agent has to be running for events to arrive.

What's the right tool if I want to never deal with webhooks again?

Honestly? Hookdeck, Svix, or a similar enterprise-grade webhook gateway. They abstract everything we've discussed in this post — signature verification, retries, idempotency, dead-letter queues, monitoring — into a managed service. You pay them more than you'd pay any of the other tools, and you stop thinking about webhook infrastructure entirely. WebhookWhisper isn't that today; it's the developer-tier wedge below it. The right answer for your team is whichever tier matches your scale.

Closing

Forwarding webhooks to localhost is one of those problems that looks shallow until you've been burned by it three times. The first time, ngrok works fine. The second time, your URL rotates while you're demoing for your team. The third time, you ship a Shopify integration that uses base64 signatures and spend an afternoon convinced your secret is wrong. (When you're ready to triage that whole class, the companion piece is how to debug webhooks.)

The tools are mature now in 2026. There's no shame in starting with ngrok, switching to Stripe CLI when you're focused on payments, and graduating to capture-and-forward when you have a team. There's also no shame in graduating again to Hookdeck or Svix when you're running production webhook infrastructure. The shame would be picking the wrong tool for what you're actually trying to do because someone wrote a thinly-veiled product pitch and called it a tutorial.

If you want to see what capture-and-forward feels like, create a free WebhookWhisper account — no credit card, no time limit, 3 permanent endpoints on the free tier. If you want the signature side of the problem solved, the signature playground runs entirely in your browser. If you want to verify our outbound signing recipes, those are at /docs/webhook-signing.

And if you want to read the production-incident postmortem of the time I accidentally created a webhook self-loop in our own forwarding engine, that's coming in a separate post. The TL;DR is: 1 webhook, 16 events, 8 seconds, two-layer fix. Engineering is humbling.

#webhooks#localhost#forwarding#ngrok#tutorial

Ready to test your webhooks?

Get a free HTTPS endpoint in under 5 seconds — no signup required.

Create Free Account
How to Forward Webhooks to Localhost (2026 Architecture Guide) | WebhookWhisper