WebSocket API
The WebSocket API pushes scene changes and viewer presence to connected clients instead of making them poll for it. It exists alongside the HTTP API rather than replacing it: the same collaborative editing is available over _scene/sync, and the two transports write to the same state, so a scene can have polling and WebSocket viewers at once and each sees the other.
What it removes is the polling interval. An edit is fanned out to the other subscribers as soon as it is recorded, which takes most of the latency out of collaborative editing.
Connecting
Upgrade at /_ws (or /api/_ws) with a standard RFC 6455 handshake.
The socket carries JSON text frames. Every message — in both directions — is an object with a type field naming it. Binary frames are accepted at the transport level for future use; the router itself reads JSON.
A connection begins unauthenticated and stays that way until the first message authenticates it. Anything other than auth on an unauthenticated connection is answered with noauth and the connection is closed.
Errors
Any message can be answered with:
{ "type": "error", "message": "notallowed" }
An error does not close the connection unless it was a protocol or authorisation failure. Common values are malformedreqs, notallowed, notsubscribed and serverfault.
Fatal failures close the connection with a WebSocket close code and one of the reasons below.
Authentication
Send auth first, carrying an existing session id — the WebSocket does not mint sessions, it adopts one you already hold.
{ "type": "auth", "sessionid": "<uuid>" }
The session is validated exactly as the HTTP path validates it: the session id, the User-Agent from the upgrade request, and the client IP address must all match. A session established from one browser cannot be moved onto a socket opened by something else.
On success:
{
"type": "auth-ack",
"userid": "<uuid>",
"expires": 1770000000.0,
"servertime": 1769000000.0,
"heartbeatMS": 30000,
"success": true
}
heartbeatMS is how often the server pings an idle connection — 30 seconds by default. It is reported rather than assumed so a client can size its own timeouts against the real value.
On failure:
{ "type": "auth-fail", "message": "noauth" }
message | Meaning |
|---|---|
malformedauth | sessionid was missing or not a UUID. |
noauth | The session is unknown, expired, or does not match this user agent and address. |
toomanyconnections | This session already holds the maximum number of connections. |
A session may hold at most 4 simultaneous connections. One multiplexed client only needs one; the rest is headroom for a second application instance on the same login, and for a reconnect racing its own not-yet-reaped predecessor. Connections that have stopped answering pings are reaped before the count is taken, so a reconnect is not refused by the dead connection it is replacing. When the limit is genuinely reached the new connection is refused rather than the oldest being evicted — evicting would let anyone holding the session id displace the real client simply by connecting.
Scene Subscription
{
"type": "scene-subscribe",
"orgid": "<orgid>",
"projid": "<projid>",
"sceneid": "<sceneid>",
"username": "Jo Bloggs",
"scenesessionid": "<uuid>"
}
username is the display name shown to other viewers. scenesessionid is optional: a client that was polling before its socket came up already has a scene session, and passing it here adopts that session rather than minting a second one — otherwise the same person would appear twice in the presence list until the first entry aged out.
Authorisation is checked per message. A valid session does not by itself grant scene access; the SceneAccess project permission is required, and notallowed is returned without it.
On success:
{
"type": "scene-subscribed",
"sceneSessionID": "<uuid>",
"servertime": 4213,
"success": true
}
servertime is the subscription's starting sequence cursor, not a clock. It is a per-scene edit sequence — treat it as opaque, and never compare it against a time or do arithmetic on it. Subscribing starts you at what is settled now, so you receive what happens from here rather than the scene's history. The cursor never advances past an edit still being written, which is what makes it safe: no change can be committed below a cursor a client has already been given.
Subscribing again without unsubscribing moves the connection to the new scene and releases the presence entry belonging to the old one.
Unsubscribing
{ "type": "scene-unsubscribe" }
The presence entry is removed, so other viewers stop seeing you. Closing the socket does the same thing.
Sending Updates
{
"type": "scene-update",
"flags": 0,
"head": { "x": 0, "y": 0, "z": 0, "h": 0, "p": 0, "r": 0 },
"handLeft": { "...": "as head" },
"handRight": { "...": "as head" },
"anchor": { "x": 0, "y": 0, "z": 0, "w": 1 },
"messages": [],
"selectedNodes": [],
"changes": { "<node-uuid>": { "...": "values" } },
"transient": false
}
This is the incoming half of _scene/sync and carries the same fields. Every part is optional; a message with only positions is a presence update.
- Presence — positions, selection and messages are recorded for the other viewers. Sent without
changes, this is a heartbeat that keeps you in the presence list. changes— scene edits, keyed by node UUID. Requires the SceneEdit permission; without it the presence half is still applied and the changes are silently dropped rather than failing the message. Keys that are not valid UUIDs are skipped.transient— marks frames that are part of an in-progress gesture, such as a drag. Same meaning as on_scene/sync: a transient change is superseded by the one that settles it rather than being kept as a separate step in the scene's history.
Sending scene-update before subscribing returns notsubscribed.
Receiving Changes
The server pushes when something happens, and coalesces presence so a busy scene does not produce a frame per viewer per tick:
{
"type": "scene-changes",
"viewers": [ { "...": "presence rows" } ],
"changes": [ { "id": "<node-uuid>", "values": { } } ],
"seq": 4271
}
viewers— every other viewer on the scene, whichever transport they arrived by. A viewer is considered present if seen within the last 30 seconds.changes— edits recorded since your cursor, excluding your own. There is no need to filter your own edits out; the server does it by scene session.seq— where your cursor now stands. Nothing needs to be echoed back: the server tracks the cursor per connection, unlike the polling path where the client carries it.
A push is only sent when there is something in it, so silence means nothing changed.
Keeping the Connection Alive
The server pings an idle connection every heartbeatMS (30 seconds by default) and expects a pong. A connection that misses a full heartbeat interval is treated as gone.
If you are deploying behind a load balancer, its idle timeout must be longer than the heartbeat, or it will close connections the server still considers healthy. The 30-second default is chosen to sit under the 60-second default idle timeout of an AWS ALB. The server's own idle keep-alive reap is 75 seconds, which must likewise stay above the balancer's idle timeout — otherwise the balancer answers a request on a connection the server has already reaped, and the client sees a gateway timeout rather than a clean close.
Close Codes
Connections are closed with a proper WebSocket close frame and a reason, rather than a bare TCP FIN:
| Reason | Close code | Cause |
|---|---|---|
malformedauth | Policy violation (1008) | auth was malformed. |
noauth | Policy violation (1008) | Authentication failed, or a message arrived before auth. |
toomanyconnections | Policy violation (1008) | The session's connection limit was reached. |
stale | Going away (1001) | The connection stopped answering pings and was reaped. |
Server-initiated closes made without a close frame surface at the client as an abnormal closure (1006) with no reason. If you see 1006, the disconnection came from the network or an intermediary rather than from the reasons above.