WebSocket
One connection per client carries requests, answers and server pushes as JSON objects in text frames. Binary frames are not part of the protocol and are ignored.
wss://your-server.example/v1/ws
The envelope#
| Direction | Frame | JSON |
|---|---|---|
| client → server | request | {"id":7,"type":"chat.send","data":{…}} |
| server → client | answer (success) | {"id":7,"ok":true,"data":{…}} |
| server → client | answer (error) | {"id":7,"ok":false,"error":{"code":"…","message":"…","details"?:…}} |
| server → client | push | {"type":"chat.message","data":{…}} |
| client → server | authenticate | {"type":"auth","data":{"token":"<access token>","protocol":1}} |
| server → client | authenticated | {"type":"auth.ok","data":{"user_id":42,"protocol":1}} |
| server → client | refused | {"type":"auth.failed","error":{…}}, then the server closes |
Rules:
idis an unsigned integer you choose (a counter per connection is enough), echoed unchanged in the answer. Every request gets exactly one answer. Answers to different requests arrive in the order the requests were sent (one socket's requests are handled one after another).datamay be left out (it then meansnull). In an answer, a missingokmeanstrueand a missingdatameansnull.- Telling frames apart: a frame with a numeric
idand (anokfield or notype) is an answer. Anything else with atypeis a push or an auth result (auth.ok,auth.failed). A push never has anidorokfield. - A malformed frame that has an unsigned-integer
idis answeredbad_request; one without a usableidcannot be answered and is dropped. - Request errors:
bad_request(malformed frame ordata),unauthorized(not authenticated yet),unknown_type,rate_limited(details.retry_after_ms),payload_too_large(the answer would exceed the message limit),unavailable(the handler took longer than 10 s),internal, plus each kind's own codes.
Connecting and authenticating#
| How | Use it when | What happens |
|---|---|---|
Authorization: Bearer <access token> on the handshake |
your WebSocket library can set headers (C#, Godot, native clients) | checked before the upgrade; a bad token is refused with an HTTP status (below) |
first message {"type":"auth","data":{"token":"…","protocol":1}} within 5 s |
browsers (the browser WebSocket API cannot set headers), or any client |
answered auth.ok or auth.failed; no auth in time: close 1008 |
?token=<access token> (or ?access_token=) on the URL |
only if the operator enabled ws.query_token (off by default) |
like the header; avoid it: proxies log URLs |
- Without a header the upgrade succeeds anonymously; the socket must send
authwithin 5 seconds. Requests sent beforeauth.okare answeredunauthorized, and pushes only reach authenticated sockets. Wait forauth.okbefore sending requests. - Every
authmessage gets exactly oneauth.okorauth.failed, also on a socket the handshake header already authenticated. - A later
authwith a fresh token of the same user re-authenticates an open socket (answeredauth.ok); a token of another user is refused (auth.failed, close 4001). auth.failedis final for that socket: the server closes it right after (4001; 4003 when banned; 4010 for an unsupportedprotocol). Itserror.codesays why (token_expired,unauthorized,banned,unsupported_protocol).- A temporary server failure while checking
auth(database down, overload) closes with 1013 withoutauth.failed: reconnect with backoff and try again. - An open socket survives the expiry of its access token. Only a revocation closes it (logout,
password change or reset, admin action, refresh-token reuse: 4001; a ban: 4003). Re-sending
authwith a fresh token is not required.
Handshake answers (HTTP, before the upgrade):
| Status | Meaning | Client action |
|---|---|---|
| 101 | upgraded | |
401 unauthorized / token_expired |
the handshake token is invalid / expired | token_expired: refresh, then connect again; unauthorized: refresh once, else log in |
| 403 | banned, an unsupported protocol version on a request that is not an upgrade, or a server rule |
do not retry |
| 426 | a plain GET without an upgrade | |
429 + Retry-After |
too many handshakes or sockets from this address | wait, then retry |
503 + Retry-After |
the server is full, shutting down, or temporarily failing | wait, then retry |
The endpoint never answers 400. Note that a browser does not show the status of a refused
handshake (the WebSocket just closes with code 1006); with first-message auth the reason
arrives as auth.failed instead.
Version mismatch#
Name your version in the x-net-backend-protocol handshake header or the protocol field of
auth. An unsupported version: the server upgrades, then closes with 4010 (with first-message
auth: auth.failed unsupported_protocol + 4010). A non-upgrade request with an unsupported
version gets 403. Do not reconnect; the client needs an update.
Heartbeats#
The server sends a WebSocket ping every 20 s and answers the client's pings. A connection that sent nothing (not even a pong) for 60 s is dropped. Browsers and most libraries answer pings automatically. Clients may send their own pings (they count against the frame rate).
Close codes#
| Code | Meaning | Reconnect? |
|---|---|---|
| 1000 | normal closure | yes |
| 1001 | the server is shutting down or redeploying | yes (soon) |
| 1006 | (set by the client library) the connection dropped without a close frame: network loss, or a refused handshake in a browser | yes, with backoff |
| 1008 | no auth in time, or still flooding after the rate limit refused requests |
yes, with backoff (fix the cause) |
| 1009 | a message over 1 MiB | yes |
| 1011 | an unexpected server error | yes, with backoff |
| 1013 | overloaded, or this socket could not keep up with its pushes | yes, later; then resync |
| 4001 | authentication refused or revoked (logout, password change, admin, refresh-token reuse) | no (see below) |
| 4003 | the account is banned | no |
| 4009 | replaced: the user opened more connections than allowed (5); the oldest goes first | no |
| 4010 | the client's protocol version is not supported | no |
Never reconnect automatically after 4000–4099. The server uses that range only for "do not come back with these credentials". For 4001: refresh the tokens once; if the refresh succeeds, connect once more with the new access token; if the refresh fails (or the new connection gets 4001 again), show the login screen. For 4009: tell the player another window or device took over; reconnect only on a user action.
For every other code, reconnect with exponential backoff and jitter (for example 1 s, 2 s, 4 s, …
up to 30 s, each ±25 %); reset the backoff after auth.ok. After a reconnect: authenticate again,
join your chat rooms again (membership ends with the connection) and reload anything you may
have missed (chat history newer than your last message, storage objects).
Kinds registered by the reference server#
| Kind | Direction | data → answer data |
|---|---|---|
auth |
client → server | {"token":string, "protocol"?:int} → auth.ok / auth.failed |
auth.ok |
server → client | {"user_id":int, "protocol":int} |
auth.failed |
server → client | (no data; error: ApiError) |
chat.join |
request | {"room":int \| string} → RoomInfo |
chat.leave |
request | {"room":int} → {} |
chat.send |
request | {"room":int, "text":string, "nonce"?:string} → {"message_id":int, "sent_at":ms} |
chat.history |
request | {"room":int, "cursor"?:string, "limit"?:int} → Page<ChatMessage> (newest first) |
chat.members |
request | {"room":int} → {"room":int, "members":[{"user":int, "name"?:string}], "count":int, "truncated"?:bool} |
chat.message |
push | ChatMessage |
chat.deleted |
push | {"id":int, "room":int} |
chat.presence |
push | {"room":int, "user":int, "event":"joined" \| "left", "name"?:string, "count"?:int} |
A game's own server may register more kinds; its /v1/asyncapi.json lists them all. An unknown
request kind is answered unknown_type; ignore push kinds you do not know.
Chat kinds in detail#
chat.join— by room id ({"room":12}) or a public room's key ({"room":"world"}). Joining a room already joined is not an error. Membership lasts as long as the connection. Group rooms need membership; DM rooms need no join (joining answers their info). Errors:not_found,not_a_member,room_full(the room's cap counts connections),quota_exceeded(16 rooms on this connection),validation_failed(an invalid key).chat.leave— leaving a room not joined is not an error.chat.send— to a room joined on this connection, or to one of your DM rooms (no join needed). The answer{"message_id","sent_at"}comes before your ownchat.messageecho. Dedupe bymessage_id(answer) =id(push), or match thenonce. The text in the push is the final text after server rules (it may differ from what you sent). Errors:validation_failed(text rules),rate_limited(details.retry_after_ms, about 2000 right after a burst),not_a_member, or a server rule's refusal (for exampleforbiddenfrom a word filter).chat.history— the same as the HTTP history route, over the socket. Public rooms: anyone; group and DM rooms: their members.chat.members— who is online in a room joined on this connection, each user once, up to 200 listed (truncated: truewhen cut),count= the total. A DM room lists only the caller: a DM never reveals whether the other player is online.chat.message— to every member connection of the room, the sender's included (also the sender's other devices). A DM's message goes to every open connection of both users, joined or not. Thenonce: in public and group rooms every member's push carries it (use a random value, never anything secret); in DMs and in the history only the sender sees it.chat.deleted— a message was deleted (moderation or its sender); remove it from the screen.chat.presence—joinedwhen a user's first connection joins the room,leftwhen their last one leaves or disconnects;countis the room's online users after the change. Your own connections get it too. Best effort: rooms with more than 100 online users get none, and each room pushes at most 10 per second;chat.membersalways answers the full list.
Generated from API.md (repository commit e25edb9, file sha256 3db87f7bba70).