net_backend

API reference

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.

text
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:

  • id is 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).
  • data may be left out (it then means null). In an answer, a missing ok means true and a missing data means null.
  • Telling frames apart: a frame with a numeric id and (an ok field or no type) is an answer. Anything else with a type is a push or an auth result (auth.ok, auth.failed). A push never has an id or ok field.
  • A malformed frame that has an unsigned-integer id is answered bad_request; one without a usable id cannot be answered and is dropped.
  • Request errors: bad_request (malformed frame or data), 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 auth within 5 seconds. Requests sent before auth.ok are answered unauthorized, and pushes only reach authenticated sockets. Wait for auth.ok before sending requests.
  • Every auth message gets exactly one auth.ok or auth.failed, also on a socket the handshake header already authenticated.
  • A later auth with a fresh token of the same user re-authenticates an open socket (answered auth.ok); a token of another user is refused (auth.failed, close 4001).
  • auth.failed is final for that socket: the server closes it right after (4001; 4003 when banned; 4010 for an unsupported protocol). Its error.code says why (token_expired, unauthorized, banned, unsupported_protocol).
  • A temporary server failure while checking auth (database down, overload) closes with 1013 without auth.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 auth with 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 own chat.message echo. Dedupe by message_id (answer) = id (push), or match the nonce. 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 example forbidden from 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: true when 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. The nonce: 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 — joined when a user's first connection joins the room, left when their last one leaves or disconnects; count is 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.members always answers the full list.

Generated from API.md (repository commit e25edb9, file sha256 3db87f7bba70).