{
    "openapi": "3.0.0",
    "info": {
        "title": "Simple HTTP Server Channels API",
        "version": "1.0.0"
    },
    "paths": {
        "/api/channels": {
            "get": {
                "summary": "List channels",
                "description": "Returns every channel the server currently holds, predefined and client-created alike. Available since shttps v3.4.0. Requires the LIST_CHANNELS channel right ('List channels' in the app). The whole feature is off by default: when 'Enable WebSocket channels' is not switched on, every endpoint under /api/channels answers 403 'Channels are disabled'. Channels live in memory only - nothing about them, including the state document of a state channel, survives a restart of the server. Note that 'participants' in a channel object is a count, not a list; the list is a separate endpoint behind its own right. 'messageRateLimitPerSecond' appears only when the channel overrides the server-wide limit. The full description of channels, their settings and the rights they need is in the article: https://shttps.phlox.dev/articles/channels/overview/ .",
                "parameters": [],
                "responses": {
                    "200": {
                        "description": "The list of channels",
                        "content": {
                            "application/json": {
                                "examples": {
                                    "An echo channel and a state channel": {
                                        "value": {
                                            "channels": [
                                                {
                                                    "id": "lobby",
                                                    "mode": "echo",
                                                    "persistent": true,
                                                    "deletable": false,
                                                    "guestsAllowed": true,
                                                    "passwordProtected": false,
                                                    "maxParticipants": 50,
                                                    "binaryAllowed": false,
                                                    "participants": 3,
                                                    "seq": 128,
                                                    "lastActivityAt": 1765900000000
                                                },
                                                {
                                                    "id": "game-42",
                                                    "mode": "state",
                                                    "persistent": false,
                                                    "deletable": true,
                                                    "guestsAllowed": false,
                                                    "passwordProtected": true,
                                                    "maxParticipants": 8,
                                                    "messageRateLimitPerSecond": 0,
                                                    "binaryAllowed": true,
                                                    "participants": 2,
                                                    "seq": 17,
                                                    "lastActivityAt": 1765900012000
                                                }
                                            ]
                                        }
                                    }
                                }
                            }
                        }
                    },
                    "403": {
                        "description": "Channels are disabled, or the user lacks the LIST_CHANNELS right"
                    },
                    "405": {
                        "description": "Method Not Allowed - only GET and POST are served on this path"
                    }
                }
            },
            "post": {
                "summary": "Create a channel at runtime",
                "description": "Creates a channel that lives until it has been empty longer than the idle timeout ('channel_idle_timeout_ms', 10 minutes by default). Available since shttps v3.4.0. Requires the CREATE channel right AND the 'Let clients create channels' setting ('allow_dynamic_channel_creation'), which is OFF by default - without it the answer is 403 even for a user holding the right. The number of channels created this way is capped by 'max_dynamic_channels' (20 by default). Every field of the body is optional and a completely empty body is a valid request, asking for an echo channel with every default. Two fields cannot be chosen here and are always true for a channel created this way: 'deletable' and 'notifyPresence'; both are configurable only for predefined channels, which are defined in the app. Note the asymmetry between the two numeric fields: 'maxParticipants' must be greater than zero, while for 'messageRateLimitPerSecond' zero is a real value meaning 'do not throttle this channel at all'. The response is the same channel object the other endpoints return, plus 'wsUrl'.",
                "requestBody": {
                    "description": "Channel settings. Every field is optional; an empty body creates an echo channel with all defaults.",
                    "required": false,
                    "content": {
                        "application/json": {
                            "schema": {
                                "type": "object",
                                "properties": {
                                    "id": {
                                        "type": "string",
                                        "description": "The channel id, which is also its address. Letters, digits, dot, dash and underscore only, up to 64 characters. Omit it and the server generates one, of the form 'c-1a2b3c4d'. An id already in use answers 409."
                                    },
                                    "mode": {
                                        "type": "string",
                                        "description": "'echo' relays every message a participant sends to the other participants, unchanged. 'state' holds one JSON document that participants change with the set/merge/delete/increment/push commands. Case-insensitive. Defaults to 'echo'."
                                    },
                                    "initialState": {
                                        "type": "object",
                                        "description": "State channels only: the document the channel starts from. Must be a JSON object. Defaults to an empty object."
                                    },
                                    "maxParticipants": {
                                        "type": "integer",
                                        "description": "How many sockets may be connected at once. Must be greater than zero. Omitted, the server-wide 'max_participants_per_channel' applies (50 by default)."
                                    },
                                    "guestsAllowed": {
                                        "type": "boolean",
                                        "description": "Whether the configured guest user may connect. Defaults to true. Set it to false to keep the channel to signed-in users even where the rest of the server accepts guests."
                                    },
                                    "messageRateLimitPerSecond": {
                                        "type": "integer",
                                        "description": "Incoming messages per second per connection. Zero means this channel is not throttled at all; a negative value answers 400. Omitted, the server-wide 'channel_message_rate_limit_per_second' applies (20 by default). Text and binary messages share the one budget."
                                    },
                                    "binaryAllowed": {
                                        "type": "boolean",
                                        "description": "Whether binary frames are relayed to the other participants, verbatim and alongside ordinary messages. Defaults to false. The size cap is the server-wide 'channel_max_message_bytes' (1 MiB by default), which applies to every channel at once and cannot be raised for one of them."
                                    },
                                    "password": {
                                        "type": "string",
                                        "description": "Plain text, sent once here and stored only as a hash. When set, everyone connecting to this channel or publishing into it must supply it, whatever else they are authorized for."
                                    }
                                }
                            },
                            "examples": {
                                "An echo channel with defaults": {
                                    "value": {
                                        "id": "lobby-42"
                                    }
                                },
                                "A private state channel": {
                                    "value": {
                                        "id": "game-42",
                                        "mode": "state",
                                        "initialState": {
                                            "players": {}
                                        },
                                        "maxParticipants": 8,
                                        "guestsAllowed": false,
                                        "messageRateLimitPerSecond": 0,
                                        "binaryAllowed": true,
                                        "password": "room-pin-1234"
                                    }
                                }
                            }
                        }
                    }
                },
                "responses": {
                    "201": {
                        "description": "Created - the channel object, plus the path to open a WebSocket to. 'wsUrl' is a path, not an absolute URL: the server does not know which scheme or host name the client reached it by.",
                        "content": {
                            "application/json": {
                                "examples": {
                                    "The created channel": {
                                        "value": {
                                            "id": "lobby-42",
                                            "mode": "echo",
                                            "persistent": false,
                                            "deletable": true,
                                            "guestsAllowed": true,
                                            "passwordProtected": false,
                                            "maxParticipants": 50,
                                            "binaryAllowed": false,
                                            "participants": 0,
                                            "seq": 0,
                                            "lastActivityAt": 1765900000000,
                                            "wsUrl": "/api/channels/lobby-42/connect"
                                        }
                                    }
                                }
                            }
                        }
                    },
                    "400": {
                        "description": "Malformed JSON body, an unusable id, an unknown mode, a maxParticipants that is not positive, or a negative messageRateLimitPerSecond"
                    },
                    "403": {
                        "description": "Channels are disabled, dynamic channel creation is disabled, or the user lacks the CREATE right"
                    },
                    "409": {
                        "description": "Conflict - the id is already taken, or 'max_dynamic_channels' has been reached"
                    }
                }
            }
        },
        "/api/channels/{id}": {
            "get": {
                "summary": "Get one channel",
                "description": "The metadata of a single channel. Available since shttps v3.4.0. Requires the CONNECT channel right - the same right that lets a client join the channel, since knowing a channel's settings and being able to connect to it are the same level of access. The channel's own password is NOT checked here; it guards connecting and publishing, not reading the metadata. The password hash is never part of the answer - 'passwordProtected' only says whether there is one. Two flags that are easy to confuse: 'persistent' means the channel is defined in the app and comes back on every server start, while 'deletable' means DELETE is allowed on it. A predefined channel can be both, in which case deleting it removes it until the server is next started.",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "description": "The channel id",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "The channel object",
                        "content": {
                            "application/json": {
                                "examples": {
                                    "A predefined echo channel": {
                                        "value": {
                                            "id": "lobby",
                                            "mode": "echo",
                                            "persistent": true,
                                            "deletable": false,
                                            "guestsAllowed": true,
                                            "passwordProtected": false,
                                            "maxParticipants": 50,
                                            "binaryAllowed": false,
                                            "participants": 3,
                                            "seq": 128,
                                            "lastActivityAt": 1765900000000
                                        }
                                    }
                                }
                            }
                        }
                    },
                    "403": {
                        "description": "Channels are disabled, or the user lacks the CONNECT right"
                    },
                    "404": {
                        "description": "No such channel"
                    },
                    "405": {
                        "description": "Method Not Allowed - GET and DELETE are served on this path"
                    }
                }
            },
            "delete": {
                "summary": "Delete a channel",
                "description": "Removes the channel and closes every socket connected to it with close code 4410 and the reason 'Channel deleted'. Available since shttps v3.4.0. Requires the DELETE channel right, and the channel itself must be marked deletable - a channel created through POST /api/channels always is, while a predefined one is only if its definition says so ('Allow deletion over the API' in the app). Deleting a predefined channel removes it until the next server start, which builds it again from the configuration.",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "description": "The channel id",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "The channel was removed",
                        "content": {
                            "application/json": {
                                "examples": {
                                    "Deleted": {
                                        "value": {
                                            "deleted": true,
                                            "id": "lobby-42"
                                        }
                                    }
                                }
                            }
                        }
                    },
                    "403": {
                        "description": "Channels are disabled, the user lacks the DELETE right, or the channel is not deletable"
                    },
                    "404": {
                        "description": "No such channel"
                    }
                }
            }
        },
        "/api/channels/{id}/participants": {
            "get": {
                "summary": "Who is connected to a channel",
                "description": "The participants currently connected to a channel, without opening a socket - meant for a dashboard, a bot or a health check. Available since shttps v3.4.0. Requires the LIST_PARTICIPANTS channel right; the same right decides whether a WebSocket connection is greeted with this list and receives join/leave events, so opening a socket is not a way around it. 'identity' is the user name and is present only when the connection is neither anonymous nor a guest; 'participantId' is always there. The channel's own password is not checked on this endpoint.",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "description": "The channel id",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "The participant list",
                        "content": {
                            "application/json": {
                                "examples": {
                                    "A named user and an anonymous one": {
                                        "value": {
                                            "channel": "lobby",
                                            "participants": [
                                                {
                                                    "participantId": "p_8f2a1c30",
                                                    "identity": "alice",
                                                    "joinedAt": 1765900000000
                                                },
                                                {
                                                    "participantId": "p_1c30a5b2",
                                                    "joinedAt": 1765900004000
                                                }
                                            ]
                                        }
                                    }
                                }
                            }
                        }
                    },
                    "403": {
                        "description": "Channels are disabled, or the user lacks the LIST_PARTICIPANTS right"
                    },
                    "404": {
                        "description": "No such channel"
                    },
                    "405": {
                        "description": "Method Not Allowed - only GET is served on this path"
                    }
                }
            }
        },
        "/api/channels/{id}/connect": {
            "get": {
                "summary": "Join a channel (WebSocket)",
                "description": "Upgrades the connection to a WebSocket and makes the client a participant of the channel. Available since shttps v3.4.0. Only the handshake is an ordinary GET request; the commands, the events, the error kinds and the close codes are described in full in the article: https://shttps.phlox.dev/articles/channels/protocol/ . Three independent layers decide who gets in, in this order: (1) the server's own authorization - with authorization off everyone connects as an anonymous participant, otherwise this path goes through the same authentication as the rest of the API and needs the CONNECT channel right; (2) guest access - a guest is refused when the channel sets 'guestsAllowed' to false; (3) the channel password, which is orthogonal to both and is always required when the channel has one, of everybody, however well authenticated and however many rights they hold. Everything is decided as a plain HTTP answer BEFORE the upgrade, so a refused client sees a real 403/404/409 instead of a socket that opens and closes; the checks are then repeated once the socket is open, because the channel can be deleted or fill up in between. Sending, in either mode, additionally needs the POST right - CONNECT on its own is a supported read-only way to use a channel, in which a state listener still receives the snapshot and everybody else's patches. Any offered Sec-WebSocket-Protocol is ignored and none is sent back, so a subprotocol cannot be used to carry a token. Connections are accepted from the same origin as the server, from any origin covered by the configured CORS rules (including a '*' rule), and from clients that send no Origin header at all; anything else gets 403 before the upgrade. A browser applies no CORS to a WebSocket handshake, so these rules are enforced by the server itself - a '*' rule therefore lets any page open a connection, carrying the user's cookies if session authentication is on. Events reach a connection in the order the channel numbered them, however many participants are sending at once, so a client may treat a 'seq' one higher than the last as proof it has missed nothing and may apply changes as they arrive. To keep that promise without letting one slow reader stall the senders, the server holds a bounded queue of pending messages per connection; a client that stops reading while the channel keeps talking exhausts it and is closed with code 4408.",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "description": "The channel id",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    },
                    {
                        "name": "password",
                        "in": "query",
                        "description": "The channel password, when the channel has one. A query parameter because a browser's WebSocket cannot set headers on a handshake; clients that would rather keep the password out of URLs and logs can send the X-Channel-Password header instead.",
                        "required": false,
                        "schema": {
                            "type": "string"
                        }
                    },
                    {
                        "name": "echoToSelf",
                        "in": "query",
                        "description": "Whether the sender also receives its own messages and patches as events, in addition to the acknowledgement. Only the value 'true' (any casing) switches it on; anything else, including omitting the parameter, leaves it off. It is a property of the connection rather than of the channel, because a simple test client and an application doing optimistic UI want opposite answers on the same channel.",
                        "required": false,
                        "schema": {
                            "type": "string",
                            "enum": [
                                "true",
                                "false"
                            ]
                        }
                    },
                    {
                        "name": "X-Channel-Password",
                        "in": "header",
                        "description": "The channel password, as a header rather than a query parameter. When both are supplied the header wins. Browsers cannot set it on a WebSocket handshake; it is meant for other clients.",
                        "required": false,
                        "schema": {
                            "type": "string"
                        }
                    },
                    {
                        "name": "Upgrade",
                        "in": "header",
                        "description": "Must be 'websocket'",
                        "required": true,
                        "schema": {
                            "type": "string",
                            "default": "websocket"
                        }
                    },
                    {
                        "name": "Connection",
                        "in": "header",
                        "description": "Must contain the 'Upgrade' token",
                        "required": true,
                        "schema": {
                            "type": "string",
                            "default": "Upgrade"
                        }
                    },
                    {
                        "name": "Sec-WebSocket-Version",
                        "in": "header",
                        "description": "WebSocket protocol version. Only 13 is supported.",
                        "required": true,
                        "schema": {
                            "type": "string",
                            "default": "13"
                        }
                    },
                    {
                        "name": "Sec-WebSocket-Key",
                        "in": "header",
                        "description": "Base64-encoded 16 byte nonce generated by the client. Browsers set this automatically.",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    },
                    {
                        "name": "Origin",
                        "in": "header",
                        "description": "Sent by browsers. The handshake is accepted when it matches the host serving the request or when a CORS rule covers it; clients that are not browsers send no Origin at all, which is also accepted.",
                        "required": false,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "101": {
                        "description": "Switching Protocols - the client is now a participant. From here the connection speaks the message protocol described in the article linked above. The transcript below is an echo channel; a state channel is greeted with a 'state' snapshot instead and takes the set/merge/delete/increment/push commands.",
                        "content": {
                            "application/json": {
                                "examples": {
                                    "Server greets a client that may see the participant list": {
                                        "value": {
                                            "type": "participants",
                                            "seq": 40,
                                            "participants": [
                                                {
                                                    "participantId": "p_8f2a1c30",
                                                    "identity": "alice",
                                                    "joinedAt": 1765900000000
                                                }
                                            ]
                                        }
                                    },
                                    "Client sends a message": {
                                        "value": {
                                            "id": 1,
                                            "command": "send",
                                            "payload": {
                                                "text": "hi"
                                            }
                                        }
                                    },
                                    "Server acknowledges, to the sender only": {
                                        "value": {
                                            "id": 1,
                                            "ok": true,
                                            "seq": 41
                                        }
                                    },
                                    "Server relays it, to everyone else": {
                                        "value": {
                                            "type": "message",
                                            "from": {
                                                "participantId": "p_8f2a1c30",
                                                "identity": "alice"
                                            },
                                            "payload": {
                                                "text": "hi"
                                            },
                                            "seq": 41,
                                            "source": "ws"
                                        }
                                    },
                                    "Server refuses a command, and stays connected": {
                                        "value": {
                                            "id": 2,
                                            "ok": false,
                                            "error": {
                                                "kind": "FORBIDDEN",
                                                "message": "Missing channel right: POST"
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    },
                    "400": {
                        "description": "Missing or malformed Sec-WebSocket-Key header"
                    },
                    "403": {
                        "description": "Channels are disabled, the user is not authenticated or lacks the CONNECT right, the channel is closed to guests, the channel password is wrong or missing, or WebSocket connections from this origin are not allowed"
                    },
                    "404": {
                        "description": "No such channel"
                    },
                    "409": {
                        "description": "Conflict - the channel already holds 'maxParticipants' connections"
                    },
                    "426": {
                        "description": "Upgrade Required - the request was not a WebSocket handshake, or asked for an unsupported protocol version"
                    }
                }
            }
        },
        "/api/channels/{id}/message": {
            "post": {
                "summary": "Publish one message without a socket",
                "description": "Sends a single message into an echo channel over ordinary HTTP - for a cron job, a webhook or a sensor that has one notification to deliver and no reason to hold a socket open. Available since shttps v3.4.0. Echo channels only: a state channel answers 409, because a payload nobody would apply to its document is a mistake worth naming rather than ignoring. Requires the POST channel right, and passes the same guest and channel-password layers as connecting does, with the password sent the same two ways (?password= or the X-Channel-Password header) - publishing must not be an easier door into a channel than a socket is. 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'. It does consume one 'seq' of the channel. Connected participants receive it as an ordinary 'message' event, distinguishable from a live one by \"source\": \"http\" and by a 'from' whose 'participantId' is null. Note that this endpoint is not covered by the per-connection message rate limit, which applies to sockets; it is covered only by the server's global HTTP rate limit, which is off by default.",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "description": "The channel id",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    },
                    {
                        "name": "password",
                        "in": "query",
                        "description": "The channel password, when the channel has one",
                        "required": false,
                        "schema": {
                            "type": "string"
                        }
                    },
                    {
                        "name": "X-Channel-Password",
                        "in": "header",
                        "description": "The channel password as a header; wins over the query parameter when both are given",
                        "required": false,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "requestBody": {
                    "description": "The message to publish",
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "type": "object",
                                "properties": {
                                    "payload": {
                                        "type": "object",
                                        "description": "Required. Any JSON value - it is relayed to the participants unchanged, exactly as the 'send' command's payload is. Missing or null answers 400."
                                    },
                                    "senderLabel": {
                                        "type": "string",
                                        "description": "Optional, self-reported. It is carried through to the recipients as 'from.label'. Nothing verifies it, so treat it as a hint about the source, not as an identity."
                                    }
                                }
                            },
                            "examples": {
                                "A notification from a cron job": {
                                    "value": {
                                        "payload": {
                                            "text": "Nightly backup finished"
                                        },
                                        "senderLabel": "backup-script"
                                    }
                                }
                            }
                        }
                    }
                },
                "responses": {
                    "200": {
                        "description": "Delivered. 'recipientCount' is how many connected sockets the message was written to.",
                        "content": {
                            "application/json": {
                                "examples": {
                                    "Publish response": {
                                        "value": {
                                            "delivered": true,
                                            "recipientCount": 3,
                                            "seq": 43
                                        }
                                    },
                                    "What the participants receive": {
                                        "value": {
                                            "type": "message",
                                            "from": {
                                                "participantId": null,
                                                "identity": "alice",
                                                "label": "backup-script"
                                            },
                                            "payload": {
                                                "text": "Nightly backup finished"
                                            },
                                            "seq": 43,
                                            "source": "http"
                                        }
                                    }
                                }
                            }
                        }
                    },
                    "400": {
                        "description": "Malformed JSON body, or a missing 'payload'"
                    },
                    "403": {
                        "description": "Channels are disabled, the user is not authenticated or lacks the POST right, the channel is closed to guests, or the channel password is wrong or missing"
                    },
                    "404": {
                        "description": "No such channel"
                    },
                    "405": {
                        "description": "Method Not Allowed - only POST is served on this path"
                    },
                    "409": {
                        "description": "Conflict - this is a state channel, which is changed with commands over a socket"
                    }
                }
            }
        }
    }
}
