🛰️ Tunnel public webhooks straight to localhost — on Cloudflare Workers + Durable Objects, with a single-binary Go CLI.
Like ngrok/Smee.io, but it's your edge, your domain, your token.
📖 Docs · Why · How it works · Install · Quickstart · Send · Tail mode · Config · Deploy · Design
Webhooks need a public URL, but the code you're debugging runs on localhost. The usual fix is a third-party tunnel that proxies a random subdomain to your machine. beam does the same job, except the whole thing is ~250 lines you own:
- 🛰️ a stable public URL —
https://beam.example.com/webhook/<name>for any provider (GitHub, Stripe, Linear…), - 🔌 delivered over one WebSocket to a local CLI, replayed to whatever port you point it at,
- 🔑 gated by your own token — only someone with the secret can claim a name and receive its traffic,
- ⚡ with nothing to run but the edge — a Durable Object holds the socket; there's no origin server, no daemon, no account but your Cloudflare one.
One worker, one Durable Object class, one Go binary. That's the system.
| 🛰️ Stable public endpoint | <your-domain>/webhook/<name> — a name maps 1:1 to a Durable Object via idFromName. |
| 🔌 WebSocket bridge | The DO holds your CLI's socket and pushes each delivery to it as a JSON frame. |
| 💤 Hibernation API | acceptWebSocket + ping/pong auto-response — idle tunnels cost ~nothing and survive evictions. |
| 🔑 Token-gated listen | The Authorization: Bearer token guards claiming a name; constant-time compared against a Worker secret. |
| 🔐 Optional per-webhook key | --key <secret> locks the delivery side too — callers must send ?key=… or get 401; the param is stripped before forwarding. |
| 🌍 All methods, all paths | GET/POST/PUT/PATCH/DELETE/HEAD pass through with verb, sub-path, and query preserved. |
| ⚡ Fire-and-forget v1 | Senders get an immediate 202; the local response isn't relayed (most providers only want a 2xx). |
👁️ --tail inspector |
Print incoming requests to stdout — pretty JSON, sorted headers — like tail -f for your webhook. |
🧾 --body-only |
Drop the metadata and emit just the body, ready to pipe into jq. |
| 🗂️ Zero-flag config | ~/.config/arjia-beam/config supplies token + server, so beam webhook listen <name> Just Works. |
| 🔁 Resilient CLI | Auto-reconnect with exponential backoff, ping/pong keepalive, clean SIGINT shutdown. |
| 📦 Single binary | Pure Go (kong + gorilla/websocket). go build and it's on your PATH. |
| 🔒 No third party | Your domain, your token, your Cloudflare account. No tunnel vendor in the middle. |
sequenceDiagram
participant S as Sender<br/>(GitHub/Stripe)
participant W as Worker
participant DO as DurableObject("name")
participant C as beam CLI
participant L as localhost:3000
C->>W: GET /webhook/name (WS upgrade + Bearer token)
W->>W: constant-time check vs API_TOKEN secret
W->>DO: route by idFromName("name")
DO-->>C: 101 Switching Protocols + {type:"connected"}
Note over S,L: …later, a webhook arrives…
S->>W: POST /webhook/name/orders?id=7
W->>DO: forward (delivery path — no auth)
DO-->>C: {type:"webhook", method, path, query, headers, body} over WS
DO-->>S: 202 Accepted (fire-and-forget)
C->>L: replay POST /orders?id=7
A Durable Object is the one primitive this problem needs: a single, globally-addressable, stateful coordination point per name. idFromName("orders") always resolves to the same instance, so the DO that holds the WebSocket is exactly the one a delivery routes to — no shared store, no pub/sub.
Prebuilt binary — grab the archive for your OS/arch from the
Releases page, extract beam,
and drop it on your PATH.
With Go:
# Build the CLI onto your PATH (~/go/bin is already there for most Go installs):
go build -o ~/go/bin/beam ./cli
# Or install straight from the repo (lands at ~/go/bin/cli — rename to beam):
go install github.com/Arjia-Labs/beam/cli@latest
mv ~/go/bin/cli ~/go/bin/beam
# Verify:
beam --version
beam --helpDeploying the edge worker is covered in Deploy your own. If you just want to use an already-deployed beam, the CLI is all you need.
beam.example.combelow is a placeholder — use the domain of your own beam deployment (see Deploy your own). There is no built-in default server, soserver(via--server,BEAM_SERVER, or the config file) is always required.
# 1. Tell the CLI who you are (once) — see Config file below.
mkdir -p ~/.config/arjia-beam
printf 'token=YOUR_TOKEN\nserver=https://beam.example.com\n' > ~/.config/arjia-beam/config
chmod 600 ~/.config/arjia-beam/config
# 2. Listen — forward everything to your local app.
beam webhook listen myhook --forward http://localhost:3000
# 3. From anywhere, hit the public URL:
curl -X POST https://beam.example.com/webhook/myhook -d '{"hello":"world"}'
# → your local server gets POST / with that body.Any request to https://beam.example.com/webhook/myhook is replayed to your forward target — all HTTP methods are accepted and the original verb is preserved. Sub-paths and query strings carry through:
…/webhook/myhook/github/callback?x=1 → http://localhost:3000/github/callback?x=1
| flag | env | default | meaning |
|---|---|---|---|
--forward |
BEAM_FORWARD |
http://localhost:3000 |
local URL to replay requests to |
--server |
BEAM_SERVER |
— (required) | your beam deployment's base URL |
--token |
BEAM_TOKEN |
— | API token (required) |
--key |
BEAM_KEY |
— | optional per-webhook delivery secret (?key=…) |
--tail |
false |
print requests to stdout instead of forwarding | |
--body-only |
false |
with --tail, print only the request body |
|
--insecure |
false |
skip TLS verify (local dev) |
beam webhook send <name> fires a delivery at a hub without you retyping the
full URL — it reuses server (and key) from your config file,
so the hub name is all you need. It's a tiny curl preset for beam: handy for
manually re-firing an event while you debug a listener.
# Body inline (Content-Type defaults to application/json):
beam webhook send myhook -d '{"event":"order.created","amount":1999}'
# Body from a file or stdin (@file / @- / -):
beam webhook send myhook -d @payload.json
jq -n '{ping:true}' | beam webhook send myhook -d -
# Sub-path, query, method, and extra headers all pass through:
beam webhook send myhook --path /github/callback --query 'id=7' \
-X PUT -H 'X-Signature: abc123' -d @body.jsonThe delivery side is open, so send needs no API token — only --key if
the listener declared one (it's added as ?key=… for you). The response status
is printed to stderr and the body to stdout; a 4xx/5xx exits non-zero.
| flag | env | default | meaning |
|---|---|---|---|
--server |
BEAM_SERVER |
— (required) | your beam deployment's base URL |
--key |
BEAM_KEY |
— | delivery secret, sent as ?key=… if required |
-X, --method |
POST |
HTTP method | |
-d, --data |
— | body; @file reads a file, @-/- reads stdin |
|
-H, --header |
— | extra Name: Value header (repeatable) |
|
--path |
— | sub-path appended to the hook | |
--query |
— | raw query string (id=7&x=1) |
|
--insecure |
false |
skip TLS verify (local dev) |
Instead of replaying to a local server, print each incoming request — method, path, sorted headers, pretty-printed JSON body — to stdout, like tail -f for your webhook. Operational logs stay on stderr, so you can redirect the request stream on its own:
beam webhook listen myhook --tail > requests.log── POST /orders?id=7 2026-06-14T22:07:12+08:00 ──
content-type: application/json
x-signature: abc123
…
{
"event": "order.created",
"amount": 1999
}
Add --body-only to drop the metadata and emit just the body (pretty-printed when JSON) — handy for piping into jq:
beam webhook listen myhook --tail --body-only | jq .eventThe CLI auto-loads ~/.config/arjia-beam/config (or $XDG_CONFIG_HOME/arjia-beam/config) so you never have to remember the token. Simple key=value lines, # comments allowed. Precedence: flag / env var > config file > built-in default.
# ~/.config/arjia-beam/config (chmod 600 — it holds your token)
token=your-api-token
server=https://beam.example.com # your own deployment's domain (required)
# key=per-webhook-secret # optional; callers must then send ?key=...
# forward=http://localhost:8080 # optional default forward targetWith that in place:
beam webhook listen myhook # zero flags → forward to localhost:3000
beam webhook listen myhook --tail # zero flags → just watch requests-
🔑 The token guards the listen side only — claiming a name and receiving its traffic over the WebSocket. Without a valid
Authorization: Bearer <token>, the upgrade is rejected401. The Worker compares it constant-time against a secret, so the name itself is the only public surface. -
📨 The delivery side is open by default — external providers must be able to POST without auth, so the unguessable name is the capability. If you need delivery authenticity, verify the provider's signature (e.g. GitHub's
X-Hub-Signature-256) in your local app — all headers are forwarded. -
🔐 Optional per-webhook secret — pass
--key <secret>when you listen and deliveries must then arrive with a matching?key=<secret>(constant-time checked) or get401. Thekeyparam is stripped before forwarding, so it never reaches your local app's logs. Use it for providers that let you set the URL yourself:beam webhook listen myhook --key s3cr3t # → POST https://beam.example.com/webhook/myhook?key=s3cr3t ✅ 202 # → POST https://beam.example.com/webhook/myhook ❌ 401
The key lives on the active listener's socket (it survives hibernation), so it's enforced for as long as you're connected.
keyis config/env-driven too (BEAM_KEY/key=in the config file).
cd worker
npm install
# The token the CLI must present to claim a name. Generate one:
openssl rand -hex 32
npx wrangler secret put API_TOKEN # paste it
npm run deploy # publishes the workerBy default this publishes to your *.workers.dev subdomain. To serve it on your own subdomain instead, uncomment the routes block in wrangler.jsonc, set the pattern to a domain you own (the apex zone must be on the same Cloudflare account), and redeploy:
For local dev, copy .dev.vars.example → .dev.vars, set API_TOKEN, then npm run dev.
worker/ ☁️ Cloudflare Worker + Durable Object (TypeScript)
├── src/index.ts router · token auth · WebhookHub DO (hibernation API)
├── wrangler.jsonc DO binding · sqlite migration · custom-domain route
├── .dev.vars.example local API_TOKEN
└── package.json wrangler scripts (dev/deploy/tail)
cli/ ⌨️ Go CLI — `beam webhook listen|send <name>`
├── main.go kong command tree + dispatch
├── listen.go connect · reconnect · ping · forward · --tail printer
├── send.go `webhook send` — fire a delivery, reusing config
├── config.go ~/.config/arjia-beam/config loader
└── go.mod kong + gorilla/websocket
flowchart TB
subgraph clients["Senders & operator"]
ext["webhook providers<br/>(GitHub · Stripe · Linear)"]
cli["beam CLI<br/>(kong + gorilla/ws)"]
end
subgraph edge["Cloudflare edge"]
w["Worker<br/>route · auth · forward"]
do["DurableObject "name"<br/>holds the WebSocket"]
end
local["localhost:3000<br/>(your app)"]
ext -->|POST /webhook/name| w
cli -->|WS upgrade + Bearer| w
w -->|idFromName| do
do <-->|JSON frames| cli
cli -->|replay| local
- 🪪 Name = identity.
idFromName(name)makes the routing trivially consistent: the DO that accepted the socket is the one a delivery lands on. No registry. - 💤 Hibernation by default.
acceptWebSocket+ aping/pongauto-response pair means an idle tunnel isn't pinned in memory and reconnects survive DO eviction. - 🔐 Constant-time token check in the Worker, before the upgrade reaches the DO — auth failures never touch durable state.
- 📦 Base64 envelope. Bodies ride inside a JSON frame as base64 so binary payloads survive; the CLI decodes and replays verbatim. (WS frame cap ≈ 1 MiB.)
- 🎀 kong for the CLI struct, with env-var fallbacks layered under the config file.
beam is deliberately tiny. It does not (yet):
- 🔁 relay the local response — v1 is fire-and-forget; the sender always gets
202. Round-trip responses are the obvious next step. - 🗃️ persist or replay history —
--tailshows live traffic; it doesn't store a log you can re-fire. - 📦 stream large bodies — the WebSocket frame cap (~1 MiB) bounds payload size.
- 👥 arbitrate multiple listeners — several CLIs on one name all receive a copy (fan-out), by design.
MIT.
Your edge, your domain, your token. 🛰️
