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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| cloudflared quick tunnel | 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 | 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 | 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 | 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 | 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.
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.
Related how-tos
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
-
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
-
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 standardForwardedheader to carry the same facts, and notes in passing thatX-Forwarded-Foralready exists in wide use. Both vendors document the earlier spelling, which is the one the servers behind them read. ↩︎ Back to text -
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