Server-sent events

SSE carries the same tenant event stream as the WebSocket, one way, server to client, over a plain HTTP response. It exists because a WebSocket upgrade is not always available — a proxy that strips upgrades, an embedded widget, a runtime with no WS client — and because a widget only ever needs to receive.

Getting on the stream is a two-step handshake.

Why two steps

EventSource cannot set request headers. There is no Authorization, no x-axis-tenant, no custom header of any kind — the browser API takes a URL and nothing else. That leaves two options for authenticating the stream: put the token in the query string, or put it somewhere the browser sends on its own.

A token in the URL ends up in access logs, proxy logs, referrer headers and browser history, so this service does not do that. Instead a normal authenticated POST exchanges your credentials for a one-time cookie, and the stream request carries that cookie automatically.

POST /v1/realtime/sse/init     ← authenticated; sets __axis_sse cookie
GET  /v1/realtime/sse/stream   ← @Public(); consumes the cookie, streams events

Step 1 — init

POST /v1/realtime/sse/init is @RealtimeControl(): it authenticates with a realtime token and is exempt from idempotency. It creates a session bound to your tenant and returns { ok: true } with:

set-cookie: __axis_sse=<sessionId>; HttpOnly; Secure; SameSite=None; Path=/v1/realtime/sse; Max-Age=60

Every attribute is doing work:

Attribute Why
HttpOnly Script cannot read it, so an XSS on the page cannot lift the stream credential
Secure HTTPS only
SameSite=None The stream is usually opened from a different origin than the API
Path=/v1/realtime/sse Sent to the stream endpoint and nowhere else
Max-Age=60 The window to open the stream is one minute, not a session

The session is one-time: GET /v1/realtime/sse/stream consumes it. Opening the stream twice with the same cookie fails on the second attempt — re-init instead of retrying.

In production the session store must be Redis-backed, because sse/init and sse/stream can land on different instances.

Step 2 — stream

GET /v1/realtime/sse/stream is @Public(), which bypasses the guard pipeline entirely. That is not a hole: the route reads the __axis_sse cookie, consumes the session, and throws invalid_jwt if there is no valid session. It is authenticated by the cookie rather than by a guard, and marking it public is what stops AuthGuard rejecting a request that carries no Authorization header by design.

Each event arrives as a standard SSE data: line containing the JSON RealtimeEvent — the same shape and the same type values documented on the WebSocket page.

Scoping is identical

A session created with a thread scope is filtered by eventMatchesThreadScope, the same function the WS gateway uses, imported and called verbatim:

const unsubscribe = this.bus.subscribe(tenantId, (event) => {
  if (scope && !eventMatchesThreadScope(event, scope)) return;
  subscriber.next({ data: JSON.stringify(event) });
});

Sharing the function is deliberate. A widget's SSE stream can never see another visitor's messages, and the two transports cannot drift into different notions of "for this thread". An event naming neither the scope's threadId nor its visitorId is dropped — fail closed.

An operator session passes no scope and streams the whole tenant.

The LiveChat variant

A website widget has no credentials at all, so it cannot call POST /v1/realtime/sse/init. It uses the public LiveChat equivalent instead:

POST /v1/livechat/:widgetKey/sse/init

@Public(), keyed by the widget key. It resolves the connection, resolves or creates the visitor's thread, and creates a thread-scoped session, then sets the same cookie with Max-Age=300 — five minutes rather than one, because a widget's handshake competes with page load and user interaction rather than with an already-running app.

{ "ok": true, "visitorId": "...", "threadId": "..." }

The tenant is derived from the connection behind the widget key, never from the caller. The visitor then opens the same GET /v1/realtime/sse/stream.

Client

// Operator / app: authenticate, then open the stream.
await fetch('https://inbox.example.com/v1/realtime/sse/init', {
  method: 'POST',
  credentials: 'include',              // required — the response sets the cookie
  headers: {
    'authorization': `Bearer ${realtimeToken}`,
    'x-axis-tenant': 'ws_...',
  },
});

const es = new EventSource('https://inbox.example.com/v1/realtime/sse/stream', {
  withCredentials: true,               // required — the request must send the cookie
});

es.onmessage = (e) => {
  const event = JSON.parse(e.data);
  switch (event.type) {
    case 'message.received': /* … */ break;
    case 'thread.reply':     /* … */ break;
    default: break;
  }
};

es.onerror = async () => {
  // The session is one-time and short-lived: re-init before reconnecting.
  es.close();
};
// Widget: no credentials, just the widget key.
const init = await fetch(
  `https://inbox.example.com/v1/livechat/${widgetKey}/sse/init`,
  {
    method: 'POST',
    credentials: 'include',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ visitorId }),   // omit to be assigned one
  },
).then((r) => r.json());

const es = new EventSource('https://inbox.example.com/v1/realtime/sse/stream', {
  withCredentials: true,
});

Both credentials: 'include' and withCredentials: true are mandatory. Omit either and the cookie is not sent, the session is never found, and the stream fails with invalid_jwt — which looks like an auth bug and is in fact a CORS-credentials one.

When to choose SSE

Use SSE when Use WebSocket when
You only need server → client You need to send frames too
A proxy or CDN blocks WS upgrades You control the network path
You are embedding a widget in someone else's page You are building an operator console
EventSource reconnection semantics are enough You want to manage reconnection yourself

SSE reconnects on its own but the session does not survive: re-run the init step before reopening the stream.