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.