lumo tunnel
Forward LumoAuth webhook events to a local URL while you're developing your handler. Equivalent to stripe listen --forward-to.
lumo tunnel --to http://localhost:3000/webhooks
The CLI prints an entry per delivered event, the HTTP status returned by your local handler, and the round-trip latency.
Why
Building a webhook receiver normally means either:
- Putting your dev machine behind a public hostname (ngrok, Cloudflare Tunnel, …), or
- Hand-firing test events with
curland hoping they look real.
lumo tunnel is a third path: a transient tunnel:// webhook on the server that the CLI subscribes to via Server-Sent Events (SSE, a long-lived HTTP response the server keeps writing events to), replaying every event to your localhost.
Flags
| Flag | Default | Purpose |
|---|---|---|
--to <url> | http://localhost:3000/webhooks | Local URL to POST events to. |
--filter <event> | (none) | Only forward this event type. Repeat for multiple. |
How it works
- lumo tunnel to LumoAuth: POST /admin/webhooks/tunnel/start
- LumoAuth to lumo tunnel: webhook_id, session_id
- lumo tunnel to LumoAuth: GET /admin/webhooks/tunnel/{id}/stream (SSE)
- Note: tenant fires events naturally
- LumoAuth to lumo tunnel: SSE: event=webhook data={delivery_id, event_name, payload}
- lumo tunnel to Your localhost: POST {to-url} with X-LumoAuth-Event headers
- Your localhost to lumo tunnel: HTTP 200
- Note: prints "✓ user.created → HTTP 200 (12ms)"
- Note: ^C
- lumo tunnel to LumoAuth: POST /admin/webhooks/tunnel/{id}/stop
The server-side tunnel:// URL prefix is recognised by WebhookService::deliver, which short-circuits the outbound HTTP call (you wouldn't want to actually POST to tunnel://session-7a3b21) but still persists a webhook_deliveries row so the SSE stream picks it up.
Headers on the relayed POST
Each forwarded event includes:
| Header | Value |
|---|---|
Content-Type | application/json |
X-LumoAuth-Event | The event name (e.g. user.created) |
X-LumoAuth-Delivery | Stable delivery ID across retries |
The body is the original webhook payload (the payload field — not the wrapped {event, timestamp, tenant, data} envelope, since you're already getting the event name via the header).
Filtering
lumo tunnel --to http://localhost:3000/webhooks --filter user.created --filter user.deleted
Filters apply client-side — the server still sends every event the organization fires; the CLI just discards non-matches before forwarding. This is intentional: filters can be changed without restarting the tunnel session.
Reconnect behaviour
The server caps each SSE connection at 10 minutes (so idle middleboxes don't drop it silently). When that happens, the CLI automatically opens a new stream with the same tunnel webhook — you'll see a one-line stream dropped … — reconnecting in 2s notice and event flow continues.
If the CLI process exits (Ctrl+C or a termination signal), the deferred cleanup calls POST /admin/webhooks/tunnel/{id}/stop, which deletes the tunnel webhook and its pending deliveries server-side. If the process is killed hard and the cleanup never runs, the tunnel:// webhook stays in the list; delete it with lumo webhooks delete <id>.
Use with lumo dev
The cleanest dev loop combines a sandbox organization with the tunnel:
SLUG=$(lumo dev start --name webhook-dev -o json | jq -r '.data.slug')
# Open the tunnel
lumo tunnel --org "$SLUG" --to http://localhost:3000/webhooks &
# Create a webhook in the sandbox (will fire alongside the tunnel)
lumo --org "$SLUG" webhooks create \
--url https://example.com/will-fail \
--events 'user.created'
# Trigger the event
lumo --org "$SLUG" users create --email test@example.com
# Watch your local handler receive POST http://localhost:3000/webhooks
Limitations
- Sync receiver only. The CLI waits at most 30 seconds for your local handler to respond. Long-running handlers should ack quickly and process asynchronously.
- One process per tunnel. Two
lumo tunnelinvocations create two independent tunnels; the same event fires through both. - No replay during disconnect. Events that fire while your CLI is offline are not forwarded after reconnect — they go to other configured webhooks normally and are visible in the audit log, but the tunnel only delivers what arrives during its connected window.