WebSocket
A WebSocket connection to /v1/realtime streams a tenant's events as they happen: inbound messages,
comments, replies, post status changes, account updates. One socket per tenant scope; the server
pushes, the client never polls.
wss://inbox.example.com/v1/realtime subprotocol: inbox.v1
Auth happens at the HTTP upgrade
Nest's guard pipeline does not run for a WebSocket. There is no controller, no interceptor and no
AuthGuard — the gateway owns the upgrade itself (noServer: true), so authentication is code in
RealtimeGateway.attach, not a decorator.
The realtime token travels in the Sec-WebSocket-Protocol header, deliberately not the URL.
Query strings end up in access logs, proxy logs and browser history; a header does not. The browser
WebSocket constructor has no way to set arbitrary headers, but it does set this one — which is why
the subprotocol slot is where the token goes.
new WebSocket('wss://inbox.example.com/v1/realtime', ['inbox.v1', token]);
handleProtocols accepts only inbox.v1 and returns false for anything else, so a client that
negotiates a different subprotocol is rejected outright. extractToken then takes the first
comma-separated value that is not inbox.v1:
const parts = header.split(',').map((s) => s.trim());
return parts.find((p) => p && p !== WS_SUBPROTOCOL);
Order does not matter, but only one non-inbox.v1 value is read.
Failure modes
Because there is no controller, failures are written straight to the socket and it is destroyed — you get an HTTP response, not a WebSocket close frame with a reason:
| Condition | Response |
|---|---|
Path is not /v1/realtime |
HTTP/1.1 404 Not Found |
| Token missing, malformed, expired or bad signature | HTTP/1.1 401 Unauthorized |
noServer means the gateway owns every upgrade on the server, so an unknown path is rejected rather
than left hanging.
The first frame
On a successful upgrade the server immediately sends:
{ "type": "connected", "tenantId": "..." }
Use it as your readiness signal. It is sent after the bus subscription is registered, so no event can slip between subscribing and the handshake completing.
Event shape
Every frame after the handshake is a JSON RealtimeEvent:
interface RealtimeEvent {
type: 'message.received' | 'comment.received' | 'thread.reply'
| 'post.status' | 'account.updated' | string;
actor?: { type: 'user' | 'service' | 'system'; id?: string; display?: string };
subject?: { type: string; id?: string; display?: string };
[key: string]: unknown;
}
type is deliberately open — business events emitted through the @Emits interceptor carry their
own names, and the union lists only the well-known ones. Treat an unrecognised type as something to
ignore, not an error.
actor says who did it and subject says what it was done to, so an event reads as a sentence.
subject.display is non-PII by convention: a name, never a phone number or email. The older UI
events omit both.
The two per-socket filters
The bus is per tenant, and every socket on that tenant subscribes to the same stream. The difference between sockets is entirely in the filter applied to each event — and those filters are the security boundary, not a convenience. Both fail closed.
const listener = (event: RealtimeEvent): void => {
if (!eventMatchesUserScope(event, tokenUserId)) return;
if (scope && !eventMatchesThreadScope(event, scope)) return;
send(event);
};
eventMatchesUserScope
An event carrying targetUserId is addressed to one person — session.revoked, published when a
specific operator's Accounts session is revoked elsewhere, is the canonical case. It reaches only
sockets whose token carries a matching userId, and never a socket with no userId or a different
one. An event without targetUserId is not user-scoped and passes through untouched.
POST /v1/realtime/tokens embeds userId only when the caller is an Accounts user session. A
machine-key token has none, so it simply never matches a user-targeted event — additive, and it
changes nothing about normal event delivery.
eventMatchesThreadScope
A token carrying threadId is a widget token, minted for an unauthenticated website visitor. It
subscribes to the tenant bus like everyone else — a bus that carries every other visitor's
conversation and every operator reply across the workspace. Only its own thread's events may reach
it.
if (event.threadId === scope.threadId) return true;
if (scope.visitorId !== undefined && event.visitorId === scope.visitorId) return true;
return false; // fail closed
An event naming neither the thread nor the visitor is dropped. That default is the point: a future
event shape without a thread key can never accidentally broadcast to a widget. Both keys are matched
because thread.reply and message.received carry threadId while the LiveChat delivery path
stamps visitorId on livechat.reply.
threadId is HMAC-signed into the token, so a visitor cannot widen their own scope by editing it.
The same function gates the SSE stream, used verbatim, so the two transports
cannot drift into different notions of "for this thread".
A complete client
Get a token first — it is short-lived, so mint it just before connecting and re-mint on reconnect.
// 1. Mint a realtime token. Standard authenticated write.
const res = await fetch('https://inbox.example.com/v1/realtime/tokens', {
method: 'POST',
headers: {
'authorization': `Bearer ${sessionId}`,
'x-axis-tenant': 'ws_...',
'idempotency-key': crypto.randomUUID(),
'content-type': 'application/json',
},
});
const { data } = await res.json();
const { token, expiresIn, wsUrl } = data; // wsUrl is '/v1/realtime'
// 2. Connect, passing the token as a subprotocol alongside inbox.v1.
const ws = new WebSocket(`wss://inbox.example.com${wsUrl}`, ['inbox.v1', token]);
ws.onmessage = (frame) => {
const event = JSON.parse(frame.data);
if (event.type === 'connected') {
console.log('subscribed to', event.tenantId);
return;
}
switch (event.type) {
case 'message.received': /* … */ break;
case 'thread.reply': /* … */ break;
default: break; // unknown types are expected
}
};
// 3. A 401 or 404 arrives as a failed upgrade, not a close reason.
ws.onerror = () => { /* re-mint the token and reconnect with backoff */ };
ws.onclose = () => { /* reconnect with backoff */ };
The response envelope is the standard { data, meta } — see Envelopes.
POST /v1/realtime/tokens is a POST without @ReadOnlyPost(), so it requires an Idempotency-Key
despite minting something disposable. See Idempotency.
Tokens expire (expiresIn seconds). There is no refresh on the socket: when it closes, mint a new
token and reconnect.
Choosing a transport
Use a WebSocket when you control the client and want the lowest-latency stream — an operator dashboard, an agent console. Use server-sent events when a WebSocket upgrade is awkward: a corporate proxy that strips upgrades, or an embedded widget that only needs server-to-client traffic.