Skip to main content

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 curl and 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​

FlagDefaultPurpose
--to <url>http://localhost:3000/webhooksLocal URL to POST events to.
--filter <event>(none)Only forward this event type. Repeat for multiple.

How it works​

10 / 10
  1. lumo tunnel to LumoAuth: POST /admin/webhooks/tunnel/start
  2. LumoAuth to lumo tunnel: webhook_id, session_id
  3. lumo tunnel to LumoAuth: GET /admin/webhooks/tunnel/{id}/stream (SSE)
  4. Note: tenant fires events naturally
  5. LumoAuth to lumo tunnel: SSE: event=webhook data={delivery_id, event_name, payload}
  6. lumo tunnel to Your localhost: POST {to-url} with X-LumoAuth-Event headers
  7. Your localhost to lumo tunnel: HTTP 200
  8. Note: prints "✓ user.created → HTTP 200 (12ms)"
  9. Note: ^C
  10. 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:

HeaderValue
Content-Typeapplication/json
X-LumoAuth-EventThe event name (e.g. user.created)
X-LumoAuth-DeliveryStable 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 tunnel invocations 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.