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:
- They rate-limit and scope by the key rather than trusting it.
- Message bodies are capped at 4000 characters, checked in the controller rather than at the store — an unbounded public endpoint is an easy way to fill someone else's database.
- An unknown key and a disabled widget return the same 404, from one shared
requireConnection()helper every route calls. If they differed, the public surface would be an oracle for enumerating which widget keys exist.
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.