Public surface
Fifteen routes answer without a credential. Every one of them is protected by something else, and none of them trusts the caller for tenancy — the two properties that make an unauthenticated route safe.
| Route | Protected by |
|---|---|
GET /v1/ping |
Nothing to protect — returns {"ok":true} |
GET /v1/health |
Reports only whether backends are wired |
GET /v1/realtime/sse/stream |
A one-time __axis_sse cookie — Max-Age=60 from /v1/realtime/sse/init, Max-Age=300 from the widget's sse/init |
GET|POST /webhooks/:integrationKey |
Per-adapter signature, verified in-handler |
POST /webhooks/email |
Shared secret in x-infobip-auth or x-axis-signature |
POST /v1/accounts/webhook |
HMAC in x-webhook-signature |
GET /v1/connections/callback |
Server-side OAuth state |
POST /v1/livechat/:widgetKey/messages |
Widget key resolves the connection, which supplies the tenant |
POST /v1/livechat/:widgetKey/session |
as above |
POST /v1/livechat/:widgetKey/sse/init |
as above |
GET /v1/livechat/:widgetKey/config |
as above |
POST /v1/livechat/:widgetKey/conversation |
as above |
GET /v1/livechat/:widgetKey/conversation/:visitorId/messages |
as above |
GET /c/:token |
The token is the authorization |
WS /v1/realtime (upgrade) |
A realtime token in Sec-WebSocket-Protocol |
Note that /webhooks/* and /c/* are not under /v1.
Liveness and health
curl -s $INBOX/v1/ping # {"ok":true}
curl -s $INBOX/v1/health
{ "status": "ok", "env": "production", "backends": { "database": true, "redis": true, "aggregator": true } }
health reports three booleans and the environment name. Never counts, never tenant or app names,
never a connection string. An unauthenticated endpoint that answers "how many conversations do you
have" is a reconnaissance surface; one that answers "is the database wired" is a health check.
The most useful line is backends.database. If it is false, the service is running on in-memory
stores and persisting nothing — while status still says ok, because liveness and correctness are
different questions.
The SSE stream
POST /v1/realtime/sse/init ← authenticated, sets the cookie
GET /v1/realtime/sse/stream ← @Public(), authenticated BY the cookie
sse/init is a normal authenticated call. It mints a session and sets:
__axis_sse=<sessionId>; HttpOnly; Secure; SameSite=None; Path=/v1/realtime/sse; Max-Age=60
# …or Max-Age=300 when minted by POST /v1/livechat/:widgetKey/sse/init (WIDGET_TOKEN_TTL_SECONDS)
sse/stream is marked @Public() only to bypass the guard pipeline; it is authenticated by that
cookie, which is consumed on use and expires in 60 seconds. The design exists because
EventSource cannot set headers, and the alternative — a token in the query string — writes a
credential into every access log and referrer. A cookie that is one-time and short-lived is the
narrower exposure.
The stream is scoped by what the session recorded. A widget session carries a thread scope, and the same filter the WebSocket gateway applies gates this stream, so a widget's SSE can never see another visitor's messages or the tenant's other conversations.
Provider webhooks
GET|POST /webhooks/:integrationKey
POST /webhooks/email
These must be unauthenticated: Meta, Infobip and the rest will not carry your credential. What
protects them is a per-adapter signature check that runs inside the handler, before anything is
persisted. Nine integration keys, and the schemes genuinely differ — HMAC in
x-hub-signature-256 for Meta, a plain shared secret for email, a header-dependent scheme for
whatsapp_session. livechat and ai always answer { ok: false } and are inert.
The GET form exists for Meta's challenge handshake. Full detail on
Signature verification.
The Accounts webhook
POST /v1/accounts/webhook receives Accounts events so Inbox can re-broadcast session.revoked onto
its realtime bus — the bridge that logs out a user's open operator tabs immediately, since those tabs
are already on Inbox's WebSocket. Verification and parsing are owned by @~lyre/auth; the controller
only routes the verified event onto the tenant bus, targeted per user.
The gotcha: with no secret configured it parses without verifying. When
accountsWebhookSecretis set, a bad signature is a 401. When it is unset — intended for dev and mocks — the handler parses the body and acts on it. In production that means anyone who can reach the route can force-log-out an arbitrary user. Set the secret.
The OAuth return leg
GET /v1/connections/callback is where the user's browser lands after authorising an account. It
carries no credential because a browser redirect cannot, and it does not need one: the connection is
completed from the server-side state recorded at /start time, which is what proves the callback
belongs to a flow this service began. The browser is then bounced to the redirectUrl stored then.
The one failure path — an unknown, expired or already-consumed state — has nowhere to redirect to, so it renders a plain HTML page rather than leaking a raw JSON API error into the user's browser.
LiveChat
Six routes, all keyed by :widgetKey:
POST /v1/livechat/:widgetKey/messages
POST /v1/livechat/:widgetKey/session
POST /v1/livechat/:widgetKey/sse/init
GET /v1/livechat/:widgetKey/config
POST /v1/livechat/:widgetKey/conversation
GET /v1/livechat/:widgetKey/conversation/:visitorId/messages
The widget key is not a secret. It ships in the page source of every site running the widget. What makes that acceptable is that it is an identifier, not a credential: it resolves to a connection, and the connection supplies the tenant. Nothing on this surface trusts the caller for tenancy, so possessing a widget key lets you start a conversation with that business and nothing else.
Three specifics:
- Unknown and disabled keys return an identical 404. Every endpoint routes through one
requireConnectionhelper for exactly this reason — if a disabled key answered differently from a nonexistent one, the surface would be a widget-key enumeration oracle. - Message bodies are capped at 4,000 characters.
- Realtime is thread-scoped.
sessionandsse/initmint a token scoped to the one visitor's thread, not a tenant-wide token, so a visitor cannot see anyone else's conversation. The token TTL is 300 seconds — long enough to outlive the handshake, short enough that a leaked one is nearly useless.
Campaign click tracking
GET /c/:token records a click, attributes it to the recipient the token was minted for, and
302-redirects to the real destination.
The person clicking is an end user, not an authenticated operator, so the token is the authorization — it resolves to exactly one link for exactly one recipient. An unknown or dead token does not error in the user's face: it 302s to the app root, which also means the endpoint cannot be used to probe whether a token ever existed.
The WebSocket upgrade
/v1/realtime is not a Nest route. The gateway attaches its own upgrade handler to the HTTP
server at bootstrap, so the request bypasses the guard pipeline entirely — Nest's guards do not
fire for a WebSocket upgrade.
Authentication happens in that handler instead. The realtime token travels in
Sec-WebSocket-Protocol (subprotocol inbox.v1) rather than the URL, to keep it out of access logs.
The gateway owns all upgrades, so a request to any other path is rejected rather than left hanging.
The shared principle
Read the list again and the pattern is the same every time. A signature, a one-time cookie, an opaque token, a server-side state value, or an identifier that resolves to the tenant — something other than a credential stands in for authentication, and in no case does the caller get to say which workspace it is acting on. Tenancy is always derived: from the connection behind a widget key, from the recipient behind a click token, from the session behind an SSE cookie.
That is the test to apply before adding anything here. If a new public route needs the caller to tell it which tenant to use, it is not a public route.