Realtime guide
Report workflows can run for minutes. Instead of polling, a client can open a Socket.IO connection and be told the moment a run finishes. This guide covers authenticating that connection, joining a run, the events you'll receive, and how to stay correct across reconnects.
00How it works
The socket never sees your access token or API key. You trade them over HTTPS for a short-lived, single-use ticket, and present that ticket when you connect. Each run of a report workflow has its own room, named by the eventId that execute returned; you join it and wait for one event.
Web apps keep the real session token in a server-side, httpOnly cookie that browser JavaScript can't read. Minting a ticket on the server and handing only that to the browser means the long-lived token never reaches the page. Native apps, extensions and servers use the same flow, so there's one way to authenticate for every client.
01Mint a ticket
Any authenticated caller can mint one: signed-in users, MSP seats, and API-key integrations. The ticket carries the caller's identity into the socket. It is valid for expiresIn seconds and can be used for exactly one connection, so mint a new one for every connection attempt, including every reconnect.
{ "ticket": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 30 }In a web app, mint the ticket in server-side code (a Server Action or API route) that already holds the user's session, and return only the ticket to the browser.
02Connect
Connect to the API host itself, not to /api. The Socket.IO endpoint is at the default path, /socket.io; if your deployment serves the API under a path prefix, prefix this path the same way (for example { path: "/prefix/socket.io" }).
import { io } from "socket.io-client";
const socket = io("https://api.example.com", {
transports: ["websocket"],
// A function, not a fixed object: it runs before every connection attempt,
// so each reconnect presents a brand-new ticket.
auth: (cb) => mintTicket().then((ticket) => cb({ ticket })),
});Who can connect
The handshake is checked twice. First the request's origin is admitted, then the ticket is verified. A failure surfaces as connect_error, never as a connection that opens and then drops.
| Client | Origin | Admitted |
|---|---|---|
| Native apps, servers, scripts | None sent | Yes |
| Browser extensions | chrome-extension://, moz-extension://, safari-web-extension:// | Yes |
| Outlook add-ins | Microsoft-hosted Outlook origins | Yes |
| Web pages | The page's own origin | Only if that origin is registered on a tenant (its allowed origins or web app URL). Ask your administrator to add it. |
| connect_error message | Meaning |
|---|---|
| Missing realtime ticket | No auth.ticket was sent. |
| Invalid or expired realtime ticket | The ticket is malformed or older than 30 seconds. Mint a new one. |
| Realtime ticket already used | Tickets are single-use. Mint a new one for each attempt. |
03Join a run
Send the eventId from execute and pass a callback to receive the acknowledgement. You can only join runs from your own tenant; any other id is answered with not_found.
The acknowledgement also reports the run's current status. If the run already finished before you joined, you'll learn that here instead of waiting for an event that has already been sent.
{
"status": "ok",
"execution": {
"status": "GENERATING",
"error": false
}
}{ "status": "error", "error": "not_found" }| Field | Values |
|---|---|
| status | ok or error |
| execution.status | GENERATING: still running, wait for the event. SUCCESSFUL: already done, fetch the report now. FAILED: already failed. |
| execution.failureReason | Present when the run failed. |
| error | not_found: no such run, or not in your tenant. wrong_channel: an API-key ticket used this event; use join_third_party_workflow. unauthorized: the connection has no identity. |
04Receive the result
{ "error": false, "data": { "...": "..." } }{ "error": true }Treat the event as a signal, and load the report from GET /api/workflows/reports/v1/:eventId. That endpoint is the source of truth: it applies report passwords and returns the same shape your client already handles from polling. Disconnect once you have the result.
05Reconnects and fallback
- Join again on every
connect, not just the first. Room membership doesn't survive a reconnect, and mobile networks drop connections often. The ack's status tells you whether you missed the result while offline. - Mint a new ticket for every attempt. Passing
authas a function, as above, does this automatically. - Keep a polling fallback. If the socket can't connect, or no event arrives, poll the report every 5 seconds. Runs can take up to 20 minutes, so keep going at least that long before giving up.
- One socket can watch several runs. Join each
eventIdon the same connection.
06Complete examples
Each example mints a ticket per connection, joins on every connect, handles the "already finished" case from the ack, and hands off to a loadReport function that calls the report endpoint.
import { io } from "socket.io-client";
export function watchRun(eventId, { mintTicket, loadReport }) {
const socket = io(SOCKET_ORIGIN, {
transports: ["websocket"],
auth: (cb) => mintTicket().then((ticket) => cb({ ticket })),
});
const finish = (outcome) => {
socket.disconnect();
return outcome === "failed" ? onFailed(eventId) : loadReport(eventId);
};
socket.on("connect", () => {
socket.emit("join_workflow", eventId, (ack) => {
if (ack.status === "error") return onJoinError(ack.error); // not_found, wrong_channel
if (ack.execution.status === "SUCCESSFUL") finish("done"); // finished before we joined
if (ack.execution.status === "FAILED") finish("failed");
});
});
socket.on("workflow_status_update", (payload) => finish(payload.error ? "failed" : "done"));
socket.on("connect_error", (err) => console.warn("socket refused:", err.message));
return socket;
}/**
* Watches one report run. The handshake auth can't be changed on the library's
* own reconnect, so reconnection is done here: every attempt is a new socket
* with a freshly minted ticket.
*/
class RunWatcher(
private val eventId: String,
private val scope: CoroutineScope,
private val realtimeApi: RealtimeApi, // POST /api/realtime/v1/ticket
private val onFinished: suspend () -> Unit, // then load the report via REST
) {
private var socket: Socket? = null
private var done = false
fun start() = scope.launch { connect() }
private suspend fun connect() {
val ticket = realtimeApi.mintTicket().ticket
val opts = IO.Options().apply {
transports = arrayOf(WebSocket.NAME)
reconnection = false
auth = mapOf("ticket" to ticket)
}
socket = IO.socket(SOCKET_ORIGIN, opts).apply {
on(Socket.EVENT_CONNECT) {
emit("join_workflow", arrayOf(eventId), Ack { args ->
val ack = args.firstOrNull() as? JSONObject ?: return@Ack
if (ack.optString("status") != "ok") return@Ack
val status = ack.getJSONObject("execution").optString("status")
if (status == "SUCCESSFUL" || status == "FAILED") finish() // finished before we joined
})
}
on("workflow_status_update") { finish() }
on(Socket.EVENT_CONNECT_ERROR) { retry() }
on(Socket.EVENT_DISCONNECT) { retry() }
connect()
}
}
private fun retry() {
if (done) return
socket?.off()
scope.launch { delay(3_000); if (!done) connect() } // re-joins on the new connection
}
private fun finish() {
done = true
socket?.off()
socket?.disconnect()
scope.launch { onFinished() } // a 404 from the report endpoint means it failed
}
}/// Watches one report run. Built-in reconnection would replay the old, already
/// used ticket, so it's turned off and each attempt connects with a fresh one.
final class RunWatcher {
private let manager: SocketManager
private var socket: SocketIOClient { manager.defaultSocket }
private var done = false
init(origin: URL) {
manager = SocketManager(socketURL: origin, config: [.forceWebsockets(true), .reconnects(false)])
}
func watch(eventId: String, mintTicket: @escaping () async throws -> String, onFinished: @escaping () -> Void) {
socket.on(clientEvent: .connect) { [weak self] _, _ in
self?.socket.emitWithAck("join_workflow", eventId).timingOut(after: 10) { items in
guard let ack = items.first as? [String: Any], ack["status"] as? String == "ok",
let execution = ack["execution"] as? [String: Any],
let status = execution["status"] as? String else { return }
if status == "SUCCESSFUL" || status == "FAILED" { self?.finish(onFinished) }
}
}
socket.on("workflow_status_update") { [weak self] _, _ in self?.finish(onFinished) }
socket.on(clientEvent: .disconnect) { [weak self] _, _ in self?.retry(mintTicket) }
socket.on(clientEvent: .error) { [weak self] _, _ in self?.retry(mintTicket) }
connect(mintTicket)
}
private func connect(_ mintTicket: @escaping () async throws -> String) {
Task {
guard let ticket = try? await mintTicket() else { return retry(mintTicket) }
socket.connect(withPayload: ["ticket": ticket])
}
}
private func retry(_ mintTicket: @escaping () async throws -> String) {
guard !done else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self, !self.done else { return }
self.connect(mintTicket) // re-joins on the new connection
}
}
private func finish(_ onFinished: () -> Void) {
done = true
socket.disconnect()
onFinished() // then load GET /api/workflows/reports/v1/:eventId
}
}07API-key integrations
Server-to-server integrations that authenticate with an API key use a separate channel. Execute with ?callAsync=true to queue a run and get an eventId, then mint a ticket with the same API key and connect as above.
| Signed-in users and seats | API-key integrations | |
|---|---|---|
| Ticket minted with | Access token | x-api-key |
| Join event | join_workflow | join_third_party_workflow |
| Result event | workflow_status_update | third_party_workflow_result |
| Result payload | { error, data }. Load the report via REST. | { error, data }. data is the workflow result. |
| Who can join | Runs from your tenant | Runs started by your app |
Using the other channel's join event is refused with wrong_channel. Acknowledgements and reconnect rules are identical on both channels.
08Adding a realtime feature
For backend developers. Workflow results are the first use of the realtime channel, not the only one. A new feature (notifications, live dashboards) should follow the same rules so clients authenticate one way for everything.
- Reuse the ticket. Don't add a new auth endpoint.
POST /realtime/v1/ticketis feature-agnostic, and the gateway's handshake middleware verifies it and stores the caller's identity on the socket. - Authorize every join. Rooms are keyed by a resource id, and a client may only join a room for a resource it owns. Check ownership with the same query the matching REST endpoint uses, so the two can never disagree.
- Acknowledge with current state. Every join returns an ack carrying the resource's current state, so a client that joins late or reconnects is immediately correct.
- Keep events as signals. Emit small payloads and let clients load the full resource through REST.
- Document it here. Add the new events, ack shape and payloads to this guide in the same change, and link the guide from the related Swagger operations.