Home API Documentation Articles Buy PLUS Releases Contacts

← Back to Articles

The channel protocol

Once a client has opened a socket to /api/channels/{id}/connect, everything else is JSON text frames in both directions. This article is the reference for them: what a client may send, what the server sends back, and what the close codes mean.

Setting channels up — enabling the feature, defining channels, permissions, passwords and limits — is the other article.

Availability — channels are available from shttps v3.4.0 and are off by default. Sending anything at all additionally needs the Send messages permission; with only Connect to channels a client receives everything and sends nothing, which is a supported way to use a channel.

Connecting

const ws = new WebSocket("wss://your-server/api/channels/lobby/connect");

The handshake is an ordinary GET, so authentication works exactly as it does for the rest of the API — a browser sends its session cookie automatically, other clients can send an Authorization header when Basic authentication is enabled. Two query parameters are understood:

ParameterDefaultMeaning
passwordThe channel password, when the channel has one. The X-Channel-Password header does the same and wins if both are sent.
echoToSelffalseWhether the sender also receives its own messages and patches as events, on top of the acknowledgement. Only the value true switches it on, in any casing.

echoToSelf belongs to the connection rather than to the channel, because two clients of the same channel reasonably want opposite answers: a test client that just prints everything wants its own messages back, and an application doing optimistic updates does not.

Everything that can refuse a connection is decided before the upgrade, so a client that may not join sees a real 403, 404 or 409 rather than a socket that opens and immediately closes. The same checks run again once the socket is open, because the channel can be deleted or fill up in between; a refusal then arrives as a close code.

Any Sec-WebSocket-Protocol a client offers is ignored, and none is sent back — a subprotocol cannot be used to smuggle a token or a password.

The envelope

A client sends one JSON object per frame. command says what to do; id is anything you like and is echoed back, so a reply can be matched to the request that caused it.

C→S  {"id": 1, "command": "send", "payload": {"text": "hi"}}

The server answers the sender, and only the sender:

S→C  {"id": 1, "ok": true, "seq": 41}
S→C  {"id": 1, "ok": false, "error": {"kind": "BAD_REQUEST", "message": "Missing payload"}}

An acknowledgement carries no copy of what was sent — the sender already knows. It carries the seq the message was given, which is the only piece of information the sender did not have.

It travels through the same per-connection queue as the events, so a sender using ?echoToSelf=true always receives its acknowledgement first and its own echoed event immediately after, in that order every time.

Separately, the server sends events to the other participants. An event has a type instead of an id, because nobody asked for it:

S→C  {"type": "message", "from": {…}, "payload": {…}, "seq": 41, "source": "ws"}

There are exactly two error kinds:

kindMeaning
BAD_REQUESTThe frame itself is wrong — malformed JSON, an unknown command, a command that this channel's mode has no use for, a missing payload or value, an unusable path.
FORBIDDENThe client lacks the Send messages permission. The message reads Missing channel right: POST.

An error frame does not end the connection. A client that misspells a command, sends a state command to an echo channel, or speaks a newer version of the protocol is told so and stays connected — it has not stopped being a participant. Only the checks made when the connection opens, and the rate limit, close a session.

seq

Every message and every patch is given the next number of a per-channel counter, and that number appears both in the sender's acknowledgement and in the event everybody else receives. Its purpose is narrow: a client can tell that it missed something. If the numbers arriving skip, something was lost.

The guarantee is per connection, and it is two things at once: every number is delivered exactly once, and they arrive in the order the channel gave them — however many participants are sending at the same moment. The server settles the order while it is numbering, and each connection has its own queue that is written out in that order, so seq means the same thing on a busy channel as on a quiet one. A client may take a number one higher than the last as proof it has missed nothing, and may apply changes as they arrive.

There is no event log and no replay. The remedy after a gap or a reconnect is to ask for the current picture again — reconnecting to a state channel delivers a fresh snapshot, and an echo channel simply carries on.

Two things deliberately do not take a number, so that the sequence a client can see stays dense:

  • Binary messages take none at all.
  • join and leave events carry the channel's current number rather than a new one — they only go to the participants allowed to see them, so numbering them would leave a hole in everybody else's sequence. Two presence events can therefore carry the same seq, and cannot be ordered by it.

Echo channels

One command, send, with a payload that is any JSON you like — a chat line, a game move, a notification are all the same primitive to the server, which never looks inside it.

C→S  {"id": 1, "command": "send", "payload": {"text": "hi"}}
S→C  {"id": 1, "ok": true, "seq": 41}                              // to the sender

S→C  {"type": "message",                                           // to everyone else
      "from": {"participantId": "p_8f2a1c30", "identity": "alice"},
      "payload": {"text": "hi"},
      "seq": 41,
      "source": "ws"}

from.identity is the user name, and is null when the connection is anonymous or a guest. from.participantId identifies the connection within the channel and is always there.

A message published over HTTP with POST /api/channels/{id}/message arrives as the same kind of event, distinguishable by two fields:

S→C  {"type": "message",
      "from": {"participantId": null, "identity": "alice", "label": "backup-script"},
      "payload": {"text": "Nightly backup finished"},
      "seq": 43,
      "source": "http"}

source is "ws" for a live participant and "http" for a one-shot publish; participantId is null for the latter, because the publisher never joined. label is the publisher's self-reported senderLabel and appears only when one was given — nothing verifies it.

State channels

A state channel holds one JSON object. A newly connected client is handed it immediately, before anything else it will receive:

S→C  {"type": "state", "seq": 40, "state": {"players": {"alice": {"score": 3}}}}

That seq is the number of the last change contained in the snapshot. Everything the connection receives afterwards is numbered above it, so a client that applies what arrives after the snapshot ends up exactly in step — nothing missed, nothing applied twice.

The document is changed with five commands, not by sending a new document. That is what lets other participants be told what changed rather than being handed the whole thing again, without anything having to compute a difference.

set

Creates or overwrites the value at path, creating any missing objects along the way. Not allowed at the root.

{"id": 1, "command": "set", "path": "players/bob", "value": {"score": 0}}

merge

A shallow merge of value over the object at path: the named keys are replaced, everything else is left alone. value must be an object. This is the only command allowed at the root, and the way to change several top-level keys at once.

{"id": 2, "command": "merge", "path": "players/alice", "value": {"score": 4, "ready": true}}
{"id": 3, "command": "merge", "value": {"round": 2, "phase": "play"}}

delete

Removes the key at path. Deleting something that is not there is a no-op rather than an error, so a client does not have to check first. Elements cannot be removed from an array by index.

{"id": 4, "command": "delete", "path": "players/bob"}

increment

Adds by to the number at path. A path with nothing at it starts from 0. This exists so that two participants raising the same counter cannot lose one another's change, which is exactly what a read-then-set would do.

{"id": 5, "command": "increment", "path": "players/alice/score", "by": 1}

push

Appends value to the array at path, creating an empty array first if nothing is there. It is the only array operation: addressing, inserting or removing by index is not supported, because it would mean shifting the neighbouring indices under everybody else at the same time. Append-only uses — a chat log, an event queue, a leaderboard — are fully covered.

{"id": 6, "command": "push", "path": "log", "value": {"text": "alice joined"}}

Paths

path is a small subset of JSON Pointer (RFC 6901):

  • Segments are separated by /. A single leading / is optional — players/alice and /players/alice address the same thing.
  • An empty path, an absent one, and "/" all mean the document root.
  • ~1 is a literal / and ~0 a literal ~. A ~ followed by anything else is refused rather than passed through, because it is a typo every time.
  • A segment is read as an array index only where an array already exists; nothing but push ever creates one. A path that runs through a plain value is an error, not permission to discard that value.

The patch event

The sender gets the usual bare acknowledgement, {"id": 2, "ok": true, "seq": 42}. Everybody else gets the command back, normalised — the applied command is the description of what changed:

S→C  {"type": "patch",
      "op": "merge",
      "path": "players/alice",
      "value": {"score": 4, "ready": true},
      "by": {"participantId": "p_8f2a1c30", "identity": "alice"},
      "seq": 42}

Three details worth knowing when you write the client that applies these:

  • op tells you how to apply value, and for increment the value is the amount added, not the new total. Add it; do not assign it.
  • by means two different things in the two directions. In an increment command it is the amount; in an event it is the participant who made the change. They never appear in the same frame.
  • path comes back exactly as the sender wrote it, so /players/alice arrives with its leading slash and players/alice without. Parse it rather than comparing it as a string.

A delete event carries no value at all.

Who else is here

A connection that holds the List participants permission is greeted with the list, and then kept up to date, so that the list it was given stays true without it having to ask again:

S→C  {"type": "participants", "seq": 40,
      "participants": [{"participantId": "p_8f2a1c30", "identity": "alice",
                        "joinedAt": 1765900000000}]}

S→C  {"type": "join",  "participant": {"participantId": "p_1c30a5b2",
                                       "joinedAt": 1765900004000}, "seq": 40}
S→C  {"type": "leave", "participant": {"participantId": "p_1c30a5b2",
                                       "joinedAt": 1765900004000}, "seq": 41}

identity is omitted from these entries when the participant is anonymous or a guest — unlike the from of a message, where it is present and null. Entries in the greeting and entries in join/leave have the same shape on purpose, so a client can merge the latter into the former.

Without the permission a connection is told nothing about anybody else — no greeting, and no presence events either; it only ever knows about itself. The events, but not the greeting, can also be switched off per channel with Announce joins and leaves.

A client can be told that somebody left without having been told they arrived — that happens when a connection is dropped in the moment between joining and being announced. Treat the list as a set and the harmless direction stays harmless. The same list is available over HTTP at GET /api/channels/{id}/participants, behind the same permission, for anything that would rather not hold a socket.

Binary messages

A channel with Relay binary messages switched on also passes binary frames straight through to the other participants, byte for byte, alongside ordinary text messages:

ws.send(blob);        // one participant
ws.onmessage = e => { /* e.data is that same Blob */ };   // the others

There is no envelope, no acknowledgement, and no seq. There is nowhere in a binary frame to put an envelope without inventing a framing both ends have to agree on, and the value of a raw relay is precisely that it needs no agreement.

What that costs is that a recipient learns nothing about a blob it did not already know — not who sent it, and not where it falls in the channel's order. A sender that needs either puts it in the bytes, or announces the blob with an ordinary text message first.

The flag is independent of the mode: a state channel may carry blobs too, and the relay never touches its document. Sending one needs the Send messages permission like anything else. A channel without the flag answers a binary frame with an error and stays connected, exactly as it would an unknown command — with a null id, because a binary frame has no request id to echo:

S→C  {"id": null, "ok": false,
      "error": {"kind": "BAD_REQUEST",
                "message": "Binary messages are not enabled on this channel"}}

Text and binary frames share one rate-limit budget, so a client cannot get a second allowance by switching frame type. Size is capped by the server-wide maximum message size, and a frame over it closes the connection with the standard code 1009 — that refusal happens in the frame reader, before any channel is consulted, which is also why the cap cannot be raised for one channel alone.

This is a relay, not a streaming transport. Each blob is queued for every recipient and then written out, and only a bounded amount can be waiting for any one connection — a recipient that falls further behind than that is dropped with 4408 rather than allowed to grow the server's memory. Since a blob may be as large as the maximum message size, that allowance is only a message or two deep. Fine for control blobs and modest image or file relay at the scale this server is for; not a video fan-out.

How a connection ends

The close code says why. Codes in the 4000 range are specific to channels; the other two are standard.

CodeMeaning
1000Normal close — either side simply finished.
1001The server is restarting or stopping. Predefined channels are rebuilt on every start, which closes the sockets attached to the previous ones.
1009A frame or message went over the maximum message size.
4403Refused: not authenticated, channels are disabled, the Connect to channels permission is missing, the channel is closed to guests, or the channel password is wrong or missing. The close reason says which.
4404No such channel — including a channel that was removed between the check and the upgrade.
4409The channel is full: maxParticipants was reached.
4410The channel was deleted with DELETE /api/channels/{id}.
4408The connection could not keep up with the channel's traffic: it fell so far behind that the server stopped holding messages for it. Reconnect and take a fresh snapshot. (The database transaction endpoint uses the same number for the opposite situation — a client that sent nothing in time. Here it is one that read nothing in time.)
4429The message rate limit was exceeded.
4500Something went wrong on the server while handling the connection.

A connection is only dropped for falling behind (4408) when it has stopped draining its socket altogether while the channel keeps talking. The server holds a bounded amount for each connection so that ordering can be guaranteed without one slow reader stalling the senders; once that allowance is used up there is no way to keep the connection both correct and connected, so it is closed rather than quietly skipped — a client silently missing patches would end up disagreeing with the server for good.

The rate limit is the one refusal that closes rather than answering. A client sending faster than the channel allows is not going to be slowed down by being told so, and answering it would cost the server a frame for every frame it is trying to refuse.

Most refusals are also available before the upgrade, as HTTP: 4403 is a 403, 4404 a 404, 4409 a 409. A client that cannot join usually learns it that way, and only meets the close codes when the situation changed while it was connecting.

A worked example: a chat in an echo channel

const ws = new WebSocket("wss://your-server/api/channels/lobby/connect");
let nextId = 1;

ws.onmessage = e => {
    const frame = JSON.parse(e.data);
    if (frame.type === "message") {
        const who = frame.from.identity || "someone";
        console.log(`${who}: ${frame.payload.text}`);
    } else if (frame.type === "join" || frame.type === "leave") {
        console.log(frame.participant.participantId + " " + frame.type + "ed");
    } else if (frame.ok === false) {
        console.warn("refused:", frame.error.kind, frame.error.message);
    }
};

ws.onclose = e => console.log("closed", e.code, e.reason);

function say(text) {
    ws.send(JSON.stringify({id: nextId++, command: "send", payload: {text}}));
}

A worked example: a scoreboard in a state channel

const ws = new WebSocket("wss://your-server/api/channels/game/connect?password=room-pin-1234");
let state = {}, lastSeq = 0;

ws.onmessage = e => {
    const frame = JSON.parse(e.data);
    if (frame.type === "state") {
        state = frame.state;                       // the whole document, once
        lastSeq = frame.seq;
    } else if (frame.type === "patch") {
        if (frame.seq !== lastSeq + 1) location.reload();   // we missed something
        lastSeq = frame.seq;
        apply(frame);                              // op + path + value
    }
    render(state);
};

ws.onopen = () => {
    ws.send(JSON.stringify({id: 1, command: "set",
        path: "players/alice", value: {score: 0}}));
    ws.send(JSON.stringify({id: 2, command: "increment",
        path: "players/alice/score", by: 1}));
    ws.send(JSON.stringify({id: 3, command: "push",
        path: "log", value: {text: "alice scored"}}));
};

The gap check is the whole point of seq: there is no replay to ask for, so a client that notices it missed a patch starts again from a fresh snapshot rather than carrying on with a document it can no longer trust. It is safe to apply each patch as it arrives, without buffering, because patches reach a connection in the order they were applied — including when several participants are changing the document at once.

Keep the check to patch events, as above. join and leave reuse the current number rather than taking a new one, so feeding presence through the same counter would report a gap that never happened.

See also