WebSockets
Subscribe to live metrics, logs, request traces and status changes over one socket.
Every live view in the dashboard — a build scrolling, a metrics chart moving, a container flipping to running — is fed by one WebSocket at /api/ws. You open a single connection and subscribe to the channels you care about, and the server pushes messages until you unsubscribe or disconnect.
OpenAPI describes request/response endpoints, so the reference entry for GET /api/ws can only say that the route exists. The protocol itself lives here.
Connecting
The socket authenticates exactly like the rest of the API — a personal access token with read scope is enough, since read is the lowest rung that reaches it:
npx wscat -c wss://belune.example.com/api/ws \
-H "Authorization: Bearer $BELUNE_TOKEN"Use wss:// on production environment, ws:// only against a local dev server.
Two things reject a connection before the upgrade completes:
- No valid credential — a
401, same as any other route. - A disallowed
Originheader. Same-origin is always accepted, and anything inCORS_ORIGINSis too. Browsers sendOriginautomatically; a script that omits it entirely is unaffected, so this only bites a browser client served from somewhere unexpected.
Browsers cannot set headers on a WebSocket, which is why the dashboard authenticates this socket with its session cookie instead. A token is for scripts.
The Protocol
Every frame is JSON. You send:
{ "action": "subscribe", "channel": "metrics:host" }action is subscribe or unsubscribe; anything else is ignored silently, as is an empty channel. A successful subscribe is not acknowledged — messages simply start arriving. A subscribe the server refuses is answered; see Who May Subscribe.
The server sends:
{ "channel": "metrics:host", "event": "metrics", "data": { } }data is passed through verbatim from whatever produced it, so its shape depends on the channel. Dispatch on channel first, then event.
Channels
| Channel | Event | Carries | Who may subscribe |
|---|---|---|---|
metrics:host | metrics | Host CPU, memory and disk, once per second | Admins |
metrics:app:{applicationId} | metrics | One application's container stats, every two seconds | Project access |
build-logs:{deploymentId} | log, done | Build output, then done with null data when the build ends | Project access |
container-logs:{sourceId} | log | Runtime log lines; sourceId is the application or database id | Project access |
requests:{applicationId} | request | Proxy request traces for one application | Project access |
requests:all | request | Every application's request traces | Admins |
container-status:{applicationId} | status | { application_id, status } when an app's container changes state | Project access |
database-status:{databaseId} | status | { database_id, status }, the database equivalent | Project access |
platform | update | { updating } when a self-update starts or fails before touching the host | Any signed-in caller |
build-logs is the only channel that terminates: it emits done with data: null when the build finishes. Everything else runs until you unsubscribe.
metrics:app:{applicationId} is collected on demand — the poller only samples applications that currently have a subscriber, so the first message can take up to two seconds to arrive. Nothing is buffered from before you subscribed, on any channel.
Who May Subscribe
Opening the socket does not entitle you to every channel. Each subscribe is checked against the same rules as the REST endpoint that serves the same data:
metrics:hostandrequests:allneed an admin, exactly likeGET /api/metrics/hostandGET /api/requests.platformis open to any authenticated caller, pinned tokens included. It carries only what the unauthenticatedGET /api/versionalready reports in itsupdatingflag, and the socket is just the faster way to hear it — treatGET /api/versionas the source of truth.- Every other channel needs access to the project that owns the resource: its owner, any member when the project is shared, or an admin. For
container-logs, the id may be an application or a database. - A project-pinned token is also limited to the projects it is pinned to.
requests:allspans every project and cannot be narrowed to a pin, so a pinned token is never given it. - Anything else is refused — a channel not listed above, an id that does not exist, or an id that is not in the lowercase, hyphenated form the API returns.
A refused subscribe is answered on the channel you asked for, and you are not subscribed to it:
{ "channel": "metrics:host", "event": "error", "data": { "error": "access denied" } }The message is identical whether the resource is forbidden or does not exist. The socket stays open and your other subscriptions are unaffected.
Access is checked once, when you subscribe, against who you were when the connection opened. A REST call re-validates its token every time; an open socket does not, so a revoked token or a demoted user keeps a live connection until it drops. Reconnecting checks everything again.
Example
Tailing a deployment's build output:
const ws = new WebSocket("wss://belune.example.com/api/ws");
ws.onopen = () =>
ws.send(JSON.stringify({ action: "subscribe", channel: `build-logs:${deploymentId}` }));
ws.onmessage = (e) => {
const { channel, event, data } = JSON.parse(e.data);
if (event === "done") return ws.close();
if (event === "log") console.log(data);
};Limits
- 20 concurrent connections per user by default (
MAX_WEBSOCKET_CONNS_PER_USER). Past that the socket is accepted and then immediately closed with1013 Try Again Later— so handle a close arriving straight afteropen. - Subscribe on one connection, not many. Channels are cheap; connections are the thing that is capped.
- Slow consumers are disconnected. Each connection has a 64-message buffer, and a client that overflows it ten times in a row is dropped. Reading in a tight loop and processing elsewhere is safer than doing real work inside the message handler, especially on
requests:allon a busy install. - The server pings every 30 seconds. Browser and library clients answer automatically; a hand-rolled client must reply to pings or it will be closed as dead.
What This Socket Cannot Do
It is read-only. subscribe and unsubscribe are the only actions — nothing here deploys, restarts or writes anything, so a read-scoped token is all it ever needs.
The host shell at /api/ws/terminal/{sessionId} is a different socket with a different protocol, and it requires a live dashboard session — no token reaches it, so it is not part of this reference.