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 accountsWebhookSecret is 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:

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.