An HTTP request is one client asking one question and getting one answer. Anything where several clients need to hear about each other — a chat, a game, a dashboard that updates itself, a phone that wants to know when the desktop changed something — does not fit that shape, and normally means either polling or a server of your own.
A channel is a WebSocket room the server hosts for you. Clients connect to /api/channels/{id}/connect and then either relay messages to one another (echo mode) or share a single JSON document the server keeps in sync (state mode). There is nothing to write on the server side: you enable the feature, define a channel, and the clients talk.
This article covers enabling channels, defining them, and who is allowed to do what. The message protocol spoken over an open connection is a separate article: the channel protocol.
Channels live in memory for as long as the server runs. A state channel's document is not saved anywhere: restart the server and every channel starts again from its configured initial state, with no participants and its counter back at zero. A predefined channel is rebuilt on each start, which closes any socket still attached to the previous incarnation with close code 1001.
That makes channels a good fit for live coordination and a poor one for anything you would be upset to lose. When a value has to survive a restart, write it to the database and use a channel to tell the other clients that you did.
Everything to do with channels is configured in the app — on Android and on desktop alike — in the WebSocket channels section:
channels_enabled) — the master switch.allow_dynamic_channel_creation) — whether clients may add their own at runtime.A predefined channel is described once in the configuration and recreated on every server start. It is never removed for being empty, and it is the only kind of channel whose every setting you can choose.
On desktop and on the command-line server the same channels can be written by hand into the configuration file, under predefined_channels:
"predefined_channels": [
{
"id": "lobby",
"mode": "ECHO",
"guestsAllowed": true,
"notifyPresence": true,
"deletable": false,
"binaryAllowed": false
},
{
"id": "game",
"mode": "STATE",
"initialState": {"players": {}},
"maxParticipants": 8,
"messageRateLimitPerSecond": 0,
"guestsAllowed": false,
"notifyPresence": true,
"deletable": true,
"binaryAllowed": true
}
]
| Field | Default | Meaning |
|---|---|---|
id | required | The channel's address. Letters, digits, dot, dash and underscore, up to 64 characters. |
mode | ECHO | ECHO or STATE. Written in upper case here, although the API reports it in lower case; both spellings are accepted wherever it is read. |
initialState | {} | State channels only: the document the channel starts from, every time the server starts. |
maxParticipants | server-wide | Connections allowed at once. Omit it to use the server-wide limit. |
messageRateLimitPerSecond | server-wide | Incoming messages per second, per connection. 0 means this channel is not throttled at all; omit the field to follow the server-wide limit. |
guestsAllowed | true | When false, only signed-in users may connect, even where guest access is configured for the rest of the server. |
notifyPresence | true | Whether join and leave events are sent — Announce joins and leaves in the app. |
deletable | false | Whether DELETE /api/channels/{id} may remove it. |
binaryAllowed | false | Whether raw binary frames are relayed alongside ordinary messages. |
password_hash | none | Written by the app when you set a password. It is a hash — there is no way to put a plain password here, so set it in the app or leave the field out. |
A malformed entry, an unusable id or a duplicate id is skipped with a warning rather than stopping the server: one bad channel definition must not be the reason the server does not come up.
Let clients create channels is off by default. Switched on, a client holding the Create channels right may POST /api/channels and get a channel back:
curl -X POST http://your-server/api/channels \
-H "Content-Type: application/json" \
-d '{"id": "lobby-42", "mode": "state", "initialState": {"players": {}}, "maxParticipants": 8}'
{"id":"lobby-42","mode":"state","persistent":false,"deletable":true,
"guestsAllowed":true,"passwordProtected":false,"maxParticipants":8,
"binaryAllowed":false,"participants":0,"seq":0,
"lastActivityAt":1765900000000,"wsUrl":"/api/channels/lobby-42/connect"}
Every field of the body is optional — an empty body asks for an echo channel with all defaults — and omitting id has the server generate one. wsUrl is a path, not a full URL: the server does not know which scheme or host name you reached it by, so prefix it yourself.
Two things cannot be chosen this way: such a channel is always deletable and always announces joins and leaves. And it is temporary — once it has been empty for longer than the idle timeout, the server removes it. Predefined channels are never collected this way, however long they sit empty.
The Limits dialog holds the caps that apply to every channel at once. The app shows some of them in friendlier units than the configuration file stores.
| Setting | Config key | Default | What it bounds |
|---|---|---|---|
| Max dynamic channels | max_dynamic_channels | 20 | How many channels clients may have created at once. Predefined ones do not count. |
| Max participants | max_participants_per_channel | 50 | Connections per channel, unless the channel sets its own. A connection over the limit is refused with 409. |
| Idle timeout (minutes in the app) | channel_idle_timeout_ms | 600000 (10 min) | How long an empty client-created channel survives. 0 keeps such channels forever. |
| Message rate limit | channel_message_rate_limit_per_second | 20 | Incoming messages per second per connection, unless the channel sets its own. 0 turns throttling off. |
| Max message size (KiB in the app) | channel_max_message_bytes | 1048576 (1 MiB) | The largest message or frame accepted on any channel. |
Changing these values in the app restarts the server, which disconnects anybody currently connected.
One more limit has no setting of its own: the server holds a bounded queue of pending messages for each connection, so that everybody receives a channel's events in the same order without one slow reader holding up the senders. The bound follows the maximum message size, so a connection may fall a message or two behind without trouble. A client that stops reading altogether while the channel keeps talking eventually exhausts it and is disconnected with close code 4408; it can reconnect and take a fresh snapshot. Dropping such a connection is deliberate — quietly skipping messages for it would leave a state-channel client disagreeing with the server with no way to notice.
When authorization is enabled, six permissions decide what a user may do with channels. They are set per user or per role on the users screen, under Channel permissions.
| Permission | Name in the API | What it allows |
|---|---|---|
| Connect to channels | CONNECT | Open a socket to a channel, and read a channel's settings with GET /api/channels/{id}. On its own it is listen-only, in both modes. |
| Send messages | POST | Everything a participant sends: the send command, all five state commands, binary frames, and publishing over HTTP. |
| Create channels | CREATE | POST /api/channels. |
| Delete channels | DELETE | DELETE /api/channels/{id}, for a channel that is deletable. |
| List channels | LIST_CHANNELS | GET /api/channels — seeing that the other channels exist at all. |
| List participants | LIST_PARTICIPANTS | Seeing who else is connected: the REST endpoint, the participant list a new connection is greeted with, and the join/leave events. |
A read-only participant is a supported way to use a channel rather than a broken one. With CONNECT but not POST, a client still receives everything: in an echo channel every message, and in a state channel the snapshot on connect and every subsequent patch. It just cannot send.
Permissions are read once, when a connection is opened; POST and LIST_PARTICIPANTS are not consulted again for each frame. Granting or revoking one therefore takes effect on the user's next connection, not the current one.
Three independent layers, in this order. All three must be satisfied.
/api/channels/… goes through exactly the same authentication as the rest of the API, and an unauthenticated request is refused with 403.The password travels either as a query parameter or as a header:
ws://your-server/api/channels/game/connect?password=room-pin-1234 X-Channel-Password: room-pin-1234
When both are supplied, the header wins. The query form exists because a browser's WebSocket cannot set headers on a handshake — only the URL — so without it the endpoint would be unusable from an ordinary web page. Anything that is not a browser should prefer the header, which keeps the password out of URLs and logs.
The password guards connecting and publishing. It is not asked for by GET /api/channels/{id} or GET /api/channels/{id}/participants — those are guarded by their permissions instead.
A WebSocket handshake is not subject to CORS in the browser. There is no preflight, and the browser does not check Access-Control-Allow-Origin before letting the connection through — a page on any origin can open a socket to any server, and the user's cookies for that server go with it. That is why this endpoint decides for itself who may connect.
The decision uses the same CORS rules you configure for ordinary requests, enforced server-side. A handshake is upgraded when it comes from the same origin as the server, when it carries no Origin header (which is everything that is not a browser), or when its origin is covered by a CORS rule — one naming it exactly, or a * rule. Anything else is answered with 403 instead of an upgrade. The rules are read at each handshake, so a change takes effect immediately.
| Endpoint | Needs | Does |
|---|---|---|
GET /api/channels | List channels | Every channel, with its settings and how many participants it has. |
POST /api/channels | Create channels | Creates one at runtime. Also needs Let clients create channels. |
GET /api/channels/{id} | Connect to channels | One channel's settings. |
DELETE /api/channels/{id} | Delete channels | Removes it and closes every socket with code 4410. The channel must be deletable. |
GET /api/channels/{id}/participants | List participants | Who is connected right now. |
GET /api/channels/{id}/connect | Connect to channels | The WebSocket handshake — see the protocol article. |
POST /api/channels/{id}/message | Send messages | Publishes one message without holding a socket. Echo channels only. |
Every parameter and response is in the API documentation, under Channels API.
A cron job, a webhook or a sensor usually has one thing to say and no reason to hold a connection open for it. POST /api/channels/{id}/message drops a single message into an echo channel:
curl -X POST "http://your-server/api/channels/lobby/message" \
-H "Content-Type: application/json" \
-d '{"payload": {"text": "Nightly backup finished"}, "senderLabel": "backup-script"}'
{"delivered":true,"recipientCount":3,"seq":43}
The publisher registers nothing: it does not become a participant, does not appear in the participant list, and does not take a slot of maxParticipants. The connected clients receive it as an ordinary message, marked "source": "http" so they can tell it apart from a live one. senderLabel is optional and self-reported — nothing verifies it, so treat it as a hint about the source rather than an identity.
It is echo channels only. A state channel answers 409: a payload nobody would apply to the document is a mistake worth naming rather than ignoring.
GET /api/system/status reports channels under its own scope, for a user holding the read status system right:
GET /api/system/status?scopes=channels
{"channels": {"enabled": true, "predefined": 3, "dynamic": 5, "totalParticipants": 12}}