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.