How-to › Integrate beyond REST

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.

Audience
API consumer
Level
beginner
Topic
Receive and send webhooks
Verified

Stripe, GitHub, and Slack deliver webhooks to a public HTTPS URL, and the handler you are writing listens on localhost:3000. To see one real payload you deploy a half-finished handler to staging, read its logs there, and lose the debugger along with twenty minutes per change. The payload you wanted to inspect is in a log line on another machine.

What you get

You will end up with real deliveries from a provider reaching a handler on your laptop through one of five tools, and a test of the restart that breaks a tunnel. This is for you if your handler only runs on localhost.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
cloudflared quick tunnelA public URL in one command, with no account, for one afternoonA host name that changes on every start, a cap of two hundred requests in flight, and no uptime guaranteeYou need the same URL tomorrow, or nobody will re-register it after each restart
gh webhook forwardGitHub repository or organization events, with no port opened to the internetOne person per repository at a time, a terminal left running, and GitHub’s events aloneThe sender is anyone other than GitHub, or the hook belongs to an app rather than a repository
ngrokA URL that survives restarts and an inspector that replays requestsAn account and authtoken, free-tier request and bandwidth caps, and a paid plan for a domain you chooseYour organization will not route test traffic through a third party’s edge
smee.io with smee-clientGitHub-style payloads into a laptop with no port opened, through a channel URLChannels anyone holding the URL can read, and payloads lost while no client is connectedThe payloads carry data you would not paste into a public URL
stripe listenStripe events, with a signing secret that stays the same across restartsThe Stripe CLI and a login, Stripe’s events alone, and a sandbox unless you pass --liveAny 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.

      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.

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 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.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, start a channel, and give its URL to the client; GitHub’s own testing page 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 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 lists as what it adds upstream.

    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.

node demo.mjs
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 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 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.

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')
})
node --test tunnel.test.mjs
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 in a second terminal while stripe listen runs, or open a GitHub hook’s recent deliveries and redeliver one. 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.

node pitfall.mjs
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.

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.

Footnotes

  1. The ngrok free plan 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. ↩︎ Back to text

  2. The three headers the relay sets are the ones ngrok documents, and all three wear the X- prefix that RFC 6648 deprecated for newly defined parameters in June 2012. RFC 7239 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. ↩︎ Back to text

  3. The smee.io repository 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.