# How to expose a local webhook receiver with a tunnel

> Get real deliveries from Stripe, GitHub, or Slack into a handler on localhost through a tunnel or a provider's forwarder, and what breaks when its URL changes.

Source: https://voxgig.com/howto/expose-a-local-webhook-receiver-with-a-tunnel

- Audience: api-consumer
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Run `cloudflared tunnel --url http://localhost:3000` for a throwaway `trycloudflare.com` URL, or `ngrok http 3000` with `--url` on a domain that survives restarts, and register that URL with the provider. For one provider's events, skip the tunnel: `stripe listen --forward-to localhost:3000/webhook` and `gh webhook forward` carry that provider's deliveries to your port without exposing it. A quick tunnel's URL dies with the process, so re-register it after every restart.

---
## You will need

A webhook handler listening on a local port; the sample's is `receiver.mjs`, which answers 200
and prints one line per delivery. Node 22 or later for the sample. For the tools themselves: an
ngrok account and its `authtoken`, the `cloudflared` binary with no account, and Node 20.18 or
later for `smee-client`. The Stripe CLI needs a login to your account, and the GitHub CLI needs
the `cli/gh-webhook` extension. Verified 2026-10-21 against Node 22.22.0. The five tunnel commands
were checked against each vendor's documentation and are not run here: `relay.mjs` stands in for
the mechanism on a local port.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [cloudflared quick tunnel](https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/) | A public URL in one command, with no account, for one afternoon | A host name that changes on every start, a cap of two hundred requests in flight, and no uptime guarantee | You need the same URL tomorrow, or nobody will re-register it after each restart |
| [gh webhook forward](https://docs.github.com/en/webhooks/testing-and-troubleshooting-webhooks/using-the-github-cli-to-forward-webhooks-for-testing) | GitHub repository or organization events, with no port opened to the internet | One person per repository at a time, a terminal left running, and GitHub's events alone | The sender is anyone other than GitHub, or the hook belongs to an app rather than a repository |
| [ngrok](https://ngrok.com/docs/gateway/agent/cli) | A URL that survives restarts and an inspector that replays requests | An account and `authtoken`, free-tier request and bandwidth caps, and a paid plan for a domain you choose | Your organization will not route test traffic through a third party's edge |
| [smee.io with smee-client](https://github.com/probot/smee.io#readme) | GitHub-style payloads into a laptop with no port opened, through a channel URL | Channels anyone holding the URL can read, and payloads lost while no client is connected | The payloads carry data you would not paste into a public URL |
| [stripe listen](https://docs.stripe.com/cli/listen) | Stripe events, with a signing secret that stays the same across restarts | The Stripe CLI and a login, Stripe's events alone, and a sandbox unless you pass `--live` | Any sender other than Stripe |

A tunnel exposes one port on your laptop to the whole internet, which is the convenience and the
exposure in one line. A quick tunnel costs you a URL that changes on each start, so the provider's
registration goes stale with it. ngrok costs an account, and a paid plan for a domain you choose.
The provider CLIs open the connection outward, so they cost nothing in exposure and carry only
that one provider's events. smee sits between the two, with a public channel URL in place of an
open port.

## Start the handler, then one tunnel

The handler is the part you already have. The sample's version does the least a receiver can do,
which is enough for every tool here: it answers 200 at once and prints what arrived.

```js title="receiver.mjs"
      const via = last(req.headers['x-forwarded-for']) ?? 'the socket'
      const proto = last(req.headers['x-forwarded-proto']) ?? 'http'
      log(`receiver   ${req.method} ${req.url}  ${body.length} bytes  from ${via}  ${proto} at the edge`)
      res.writeHead(200, { 'content-type': 'application/json' })
      res.end('{"ok":true}')
```

With it listening on port 3000, each tool is one command. `tunnels.sh` collects them, with the
values you replace in upper case.

```bash title="tunnels.sh"
ngrok http 3000
ngrok http 3000 --url https://YOUR_DEV_DOMAIN
cloudflared tunnel --url http://localhost:3000
npm install --global smee-client
smee --url https://smee.io/CHANNEL_ID --path /webhook --port 3000
stripe listen --forward-to localhost:3000/webhook
gh extension install cli/gh-webhook
gh webhook forward --repo=OWNER/REPO --events=push,pull_request --url=http://localhost:3000/webhook
```

The first `ngrok` line puts the port on the domain every account gets, which the
[domains documentation](https://ngrok.com/docs/gateway/domains) calls a stable, auto-assigned
host name. The second names it with `--url`, the flag that replaced `--domain`. A domain you
choose needs a paid plan. The free plan caps HTTP requests at twenty thousand a month and four
thousand a minute, and transfer at a gigabyte a month, per its
[limits page](https://ngrok.com/docs/pricing-limits/free-plan-limits).[^1]

`cloudflared` prints a temporary `trycloudflare.com` URL, needs no account, and changes the host
name each time you start it. For smee, open [smee.io](https://smee.io/), start a channel, and give its URL to the client;
GitHub's own [testing page](https://docs.github.com/en/webhooks/testing-and-troubleshooting-webhooks/testing-webhooks)
documents that command and the `Forwarding` line it prints.

Two of the five leave nothing to register. `stripe listen` prints
`Ready! Your webhook signing secret is whsec_...` and forwards your account's events with no
endpoint configured in the Dashboard; its documentation adds that the secret does not change
between restarts. `gh webhook forward` creates the hook for you on a repository or, with
`--org`, an organization, the only two kinds of hook it supports. For the other three, paste
the public URL plus your path, `/webhook` here, into the provider's webhook settings.

Slack checks a Request URL as you save it: it posts a
[`url_verification`](https://docs.slack.dev/reference/events/url_verification/) event and expects
the `challenge` value back. The sample's fixed answer fails that check.

## Watch what the tunnel adds to a request

A tunnel has two halves: an edge the provider can reach, and an agent on your machine that
forwards to your port. `relay.mjs` is the agent half with the internet removed. It listens on one
local port in place of the edge, forwards to the handler, and sets the three headers the ngrok
[HTTP endpoint documentation](https://ngrok.com/docs/gateway/endpoints/http) lists as what it
adds upstream.

```js title="relay.mjs"
    const headers = {
      ...entry.headers,
      // The sender addressed the public name, and ngrok passes Host on as it
      // arrived unless a traffic policy rewrites it.
      host: publicHost,
      // TLS ended at the edge, so the handler sees plain HTTP and learns the
      // original scheme, host and client address only from these three.
      'x-forwarded-for': append(entry.headers['x-forwarded-for'], entry.from),
      'x-forwarded-proto': append(entry.headers['x-forwarded-proto'], 'https'),
      'x-forwarded-host': entry.headers['x-forwarded-host'] ?? publicHost,
    }
```

`demo.mjs` starts the handler and the relay, registers the relay's address with a stand-in
provider that posts GitHub-shaped deliveries, and then replays one request from the relay's
buffer.

```bash
node demo.mjs
```

```text output
handler listens on 127.0.0.1:34369
tunnel  listens on 127.0.0.1:43847 and stands in for https://quiet-otter-1234.example.com
provider registered http://127.0.0.1:43847/webhook

receiver   POST /webhook  44 bytes  from 127.0.0.1  https at the edge
provider   push -> 200
receiver   POST /webhook  31 bytes  from 127.0.0.1  https at the edge
provider   pull_request -> 200

headers the handler saw on the first delivery:
  host: quiet-otter-1234.example.com
  x-forwarded-host: quiet-otter-1234.example.com
  x-forwarded-proto: https
  x-forwarded-for: 127.0.0.1
  x-github-event: push
  x-github-delivery: 00000000-0000-4000-8000-000000000001
  user-agent: GitHub-Hookshot/stand-in

receiver   POST /webhook  44 bytes  from 127.0.0.1  https at the edge
replay of request 1 from the tunnel buffer -> 200, handler now holds 3 deliveries
```

The handler's socket peer is the agent, so the sender's address arrives only in
`x-forwarded-for`. `host` still carries the public name, because ngrok rewrites it only when a
traffic policy asks. ngrok appends to `x-forwarded-for` and `x-forwarded-proto` rather than
replacing them, so `receiver.mjs` reads the last value of each: anything in front of it is
whatever the sender wrote.

Cloudflare documents the same two plus
[`CF-Connecting-IP`](https://developers.cloudflare.com/fundamentals/reference/http-headers/) for
the client address.[^2] `x-forwarded-proto` says `https` because TLS ended at the edge, which is
also why your handler sees plain HTTP from a tunnel that the provider reached over HTTPS.

The replay is the inspector's trick. The ngrok
[web interface](https://ngrok.com/docs/gateway/agent/web-inspection-interface) on `localhost:4040`
keeps each request behind a Replay button. The replay goes from the agent straight to your service,
so the provider is not involved and the registration plays no part. The relay does the same from
its buffer, and the handler logs a third delivery with the first one's body.

## Check it worked

`tunnel.test.mjs` pins the mechanism. A delivery arrives with its body and event headers intact,
the forwarding headers land after any the sender set, and a replay repeats a kept request body
for body. The two restart
cases in the next section are tests too.

```js title="tunnel.test.mjs"
test('a delivery through the relay reaches the handler with its body and event headers', async (t) => {
  const tunnel = await startRelay({ target: handler.port })
  t.after(() => tunnel.close())
  const provider = createProvider()
  provider.register(`${tunnel.url}/webhook`)
  const result = await provider.deliver('push', { ref: 'refs/heads/main' })
  assert.equal(result.status, 200)
  const got = handler.deliveries.at(-1)
  assert.equal(got.path, '/webhook')
  assert.equal(got.body, '{"ref":"refs/heads/main"}')
  assert.equal(got.headers['x-github-event'], 'push')
})
```

```bash
node --test tunnel.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 165.042032
```

Seven tests pass. With a real tool the check is one event. Run
[`stripe trigger payment_intent.succeeded`](https://docs.stripe.com/cli/trigger) in a second
terminal while `stripe listen` runs, or open a GitHub hook's recent deliveries and
[redeliver one](https://docs.github.com/en/webhooks/testing-and-troubleshooting-webhooks/redelivering-webhooks).
The handler prints its `receiver` line with the event name.

## When it goes wrong

The provider's delivery log shows failures and the handler prints nothing. The tunnel was
restarted, came back on a different host name, and the provider still holds the first one.
`pitfall.mjs` runs that restart twice: once with an address nobody re-registers, and once with a
name the provider resolves when it sends. The second is what an auto-assigned or reserved domain
on ngrok gives you.

```bash
node pitfall.mjs
```

```text output
1. a random URL, re-registered by nobody
   registered: http://127.0.0.1:38115/webhook
   first delivery -> {"status":200}
   tunnel restarted on http://127.0.0.1:38415, the provider was not told
   second delivery -> {"error":"ECONNREFUSED 127.0.0.1:38115"}
   deliveries the handler logged: 1

2. a stable name, resolved by the provider when it sends
   registered: https://hooks.example.com/webhook
   first delivery -> {"status":200}
   tunnel restarted, the name points at the new agent
   second delivery -> {"status":200}
   deliveries the handler logged: 3
```

The second delivery in the first run never reaches the laptop, so the only trace is on the
provider's side. Re-register the URL after each restart of a quick tunnel, or start the tunnel on
a name that outlives the process. A provider CLI avoids the problem: its connection goes outward,
so there is no URL to go stale.

`gh webhook forward` stops with `Hook already exists`. Forwarding supports one person at a time
per repository or organization, so a colleague is already forwarding the same hook. Take turns, or
fork a test repository each.

A payload sent to a smee channel never arrives. The channel relays to clients connected at that
moment and the server stores nothing, so a delivery sent while the client was down is gone.[^3]
Keep the client running, and redeliver from the provider's log when it was not.

## When not to do this

Do not leave a quick tunnel as the registered endpoint past the session you started it for.
Cloudflare's documentation gives quick tunnels no uptime guarantee, caps them at two hundred
requests in flight, and reserves them for testing and development, and GitHub says the same of
`gh webhook forward`.

Do not treat the random host name as a secret. Anyone who posts to it reaches your handler, so the
signature check your handler does in production stays on while you test. A smee channel is
weaker still: its documentation says channels are not authenticated, so anyone with the channel
ID can read the payloads.

Do not point live-mode events at a laptop. `stripe listen` runs against a sandbox unless you pass
`--live`, and GitHub hooks on a throwaway repository cost nothing, so a tunnel never needs to see
a customer's payment or a production repository's pushes.

Do not build the forwarding half yourself for anything but a test, as `relay.mjs` does here. The
real agents hold a TLS edge, retries, and the inspector, and reproducing them is a project rather
than a page.

## Related how-tos

- [Acknowledge a webhook fast and process it from a queue](/howto/acknowledge-webhooks-fast-and-process-from-a-queue)

- [Verify a Standard Webhooks signature in PHP](/howto/verify-a-standard-webhooks-signature-in-php)

## Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed. No tunnel was run: `relay.mjs` stands in for the agent half of one on a local port. The
commands in `tunnels.sh`, the headers, the limits, and the messages this page quotes were read
from each vendor's documentation. The `smee-client` requirement was read from the npm
registry.

[^1]: The ngrok [free plan](https://ngrok.com/docs/pricing-limits/free-plan-limits) also puts an
interstitial page in front of all HTML browser traffic, to deter phishing. A visitor who clicks
through is spared it for seven days by a cookie. A webhook sender has no browser, clicks nothing,
and sets no cookie, so the page that greets you when you open your own tunnel URL is one your
provider never sees.

[^2]: The three headers the relay sets are the ones ngrok documents, and all three wear the `X-`
prefix that [RFC 6648](https://www.rfc-editor.org/rfc/rfc6648) deprecated for newly defined
parameters in June 2012. [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239) followed in June 2014
with a standard `Forwarded` header to carry the same facts, and notes in passing that
`X-Forwarded-For` already exists in wide use. Both vendors document the earlier spelling, which is
the one the servers behind them read.

[^3]: The [smee.io repository](https://github.com/probot/smee.io) answers its own question about
storage: payloads are never stored on the server or in any database, because the server is a
pass-through. It then adds that payloads are stored in your browser's `localStorage`, so
revisiting a channel page shows what you saw there last. The one durable copy of a payload that
crossed smee is in the browser of whoever watched it arrive.