LiveChat

LiveChat is the one channel where inbox owns both ends. There is no vendor API, no OAuth, and no provider webhook — a website visitor talks to an agent through a widget inbox itself serves. That inverts the usual adapter shape in two places, and both are worth understanding before you build against it.

Inbound is a public endpoint, not a signed webhook. A browser on a customer's marketing site cannot hold a service key, and shipping one to the page would be worse than having none.

Outbound is a publish, not an HTTP call. The visitor is holding a socket open, so the adapter delivers an agent's reply onto the realtime bus rather than POSTing it anywhere. The LiveChatAdapter is constructed with a callback that resolves the connection, then publishes a livechat.reply event on that connection's tenant channel.

The adapter serves messages and connection on the livechat network. startConnect throws — a widget is created, not authorised, so callers create one through the import path, which mints the widget key.

The widget key is not a secret

It sits in the page source of every site running the widget. Anyone can read it. The endpoints are built on that assumption rather than against it:

The security core: the connection supplies the tenant

Nothing on this surface trusts the caller for tenancy. The widget key resolves to a connection via findByExternalAccountAnyTenant('livechat', widgetKey), and that connection's tenantId is the tenant. Sending an x-axis-tenant header gets a visitor nowhere: the value is never read on these routes, so there is no path from a public request to another tenant's inbox.

The same holds one level down. The thread id is resolved server-side from the visitor id via resolveLiveChatThread(connection.id, visitorId), never taken from the request — so a visitor cannot request another visitor's thread by naming its id.

The visitor id is the thread key. A visitor without one is a first-time visitor: the controller mints v_<uuid> and hands it back so the widget can reuse it for the rest of the session. It also plays the role of the contact key, since a widget visitor is identified only by that id until they leave a phone or email.

Routes

All six are @Public(), under /v1/livechat/:widgetKey.

Route Body Returns
POST /messages { visitorId?, body, visitorName? } { ok: true, messageId, visitorId }
POST /session { visitorId? } { token, expiresIn, wsUrl, visitorId, threadId }
POST /sse/init { visitorId? } { ok: true, visitorId, threadId } + __axis_sse cookie
GET /config { success: true, widget: { slug, name, status, uses_ai, greeting_message?, ui_config?, faqs?, channels? } }
POST /conversation { visitorId?, visitorName? } { success: true, conversation_id, visitorId }
GET /conversation/:visitorId/messages { success: true, conversation: { id }, messages: [{ id, body, sender, created_at }] }

sender is visitor for inbound and agent for outbound. conversation_id is the visitor id — the external thread key — so a returning widget's history call resolves the same thread.

The config, conversation and messages shapes deliberately mirror axis-api's WidgetPublicController, so an existing widget changes only its base URL and how it acquires a stream, not how it parses responses. config carries no assistant_id and no pusher block: the widget is pure-inbox now, AI-vs-human is inbox's concern, and realtime is inbox SSE.

POST /messages builds its event through adapter.normalizeWebhook(...) — the same normalisation path a signed provider webhook takes — and hands it to inbox.ingest(). Threads are never created directly; upsertThread runs only from ingest.

The thread-scoped realtime token

An agent's reply is published to the tenant bus. A widget cannot hold a tenant-wide realtime token, because that would stream it every other visitor's conversation. POST /session mints a token scoped to one thread instead:

appId       livechat:<connection.externalAccountId>
tenantId    <connection.tenantId>
scopes      ['inbox']
threadId    <resolved server-side>
visitorId   <caller's or freshly minted>
ttlSeconds  300
jti         <uuid>

The gateway subscribes that token to the tenant bus and then drops every event that does not match the token's thread. The scope, not the sub, is what bounds what the token can see — appId is a synthetic value that keeps the token attributable to the widget without inventing an Accounts app, and grants nothing on its own. scopes: ['inbox'] is read-only realtime; a widget never writes over this channel, it posts messages over HTTP.

The 300-second TTL is short on purpose. The token only has to outlive the handshake to the SSE or WS stream, which reconnects with a fresh one; a longer TTL just widens the window in which a leaked token is useful.

POST /sse/init is the same thing for proxies that will not do a WS upgrade. It creates a thread-scoped SSE session and sets a one-time cookie:

Set-Cookie: __axis_sse=<sessionId>; HttpOnly; Secure; SameSite=None; Path=/v1/realtime/sse; Max-Age=300

The visitor then opens GET /v1/realtime/sse/stream, which the gateway filters to that one thread with the same filter the WS token uses.