Skip to main content

Client–server protocol

Applies to: SimpleTwo 0.9.x · Protocol version: 1 · Checked: 20.09.2026

This is a contract, not a description of current behaviour: what is written here is what the product owes you, and it may change only by the rules in §14. It is addressed to anyone writing their own client, a bot, a bridge or an integration.

1. The one rule everything else follows​

The identifier is the server's, the name is the reader's, and a payload a client wrote is never an identity.

The service authenticated the sender; it is therefore the only party that can say who is speaking. A client says what it is doing and to whom — never who it is.

Three consequences, applied everywhere below:

  1. The server stamps identity. sender, from and user are written by the service from the authenticated session. Where possible the request type has no such field at all, so a client cannot state it.
  2. The server validates addressing. to is checked against membership, not trusted.
  3. A field that must be ignored must be removed. A field the reader is told to skip is a field the next reader trusts.

2. Transport and authentication​

Basehttps://<messaging host>/v1
AuthAuthorization: Bearer <token> on every request, including the socket handshake
Botsthe same header with a bot_… token; bounded by capability scopes and a conversation allowlist, and every request is audited, reads included
ErrorsJSON {"code": "<identifier>", "error": "<sentence>"}
Eventsone WebSocket: GET /v1/ws?token=…&device=…

Status codes carry meaning and clients must distinguish them:

CodeMeansClient behaviour
400the request is malformed or names something unknowna bug: report it, do not retry
401the token is absent, expired or revokedrenew and retry once
403authenticated, and not alloweddo not retry: this is an answer, not a failure
404no such thing, or none this caller may seetreat as absent
409conflicting state (duplicate join, taken name)resolve and retry
410this shape is gone; the message names its replacementfix the client
429rate limitedback off; the socket path is not affected

403 deliberately covers both "does not exist" and "not yours" wherever telling them apart would disclose a directory the caller cannot read.

3. Protocol version, and what an old client is told​

One monotonic integer over the whole contract — HTTP shapes and socket frames together. Not semver: the only question anyone asks is "is this client older than the server requires", and that is a comparison. One number for both surfaces because they are one contract: the day they version apart is the day a client can satisfy half of it.

HeaderDirectionMeans
X-SimpleTwo-Protocolserver → clientwhat this service speaks
X-SimpleTwo-Protocol-Minserver → clientthe oldest client it still serves
X-SimpleTwo-Client-Protocolclient → serverwhat this client speaks

Headers, not body fields: a client must be able to learn it is behind from a call it was already making — including one that failed — and a header survives a 4xx whose body the client does not understand.

Three states, and all three must exist:

Client vs serverServerClient
currentnormalnothing
behind, ≥ Minnormal, counted per versiona non-blocking "update available" notice
below Minrefuse: client_too_olda blocking screen: this build cannot talk to this server
The middle row is the point

Without it every contract change is a choice between breaking people silently and carrying an alias forever, and an alias can never be retired because the last old client cannot be proven gone. With it a deprecation has an end: raise Min on a date, having watched the population report, with users having been told for weeks by their own app.

The service reports the population, not just the refusals: /healthz and the console break connected clients down by protocol version and platform. A single count is not enough to decide on — "0.4% of Android on protocol 5" and "31% of iOS on protocol 5" are the same "greater than zero" and completely different decisions.

4. Errors have codes​

An error is {"code", "error"}: the code is what a client branches on, the sentence is for the log. The client owns the wording — translations live in the client core. The catalogue is on the error codes page and is enforced in CI: a code the service can send and no client can translate is a build defect, not a translation detail.

A code is an identifier: never renamed, never reused. Renaming one silently changes what an already-installed client displays.

5. Three surfaces, and why they are three​

SurfaceCarriesPersistedExample
Messagesconversation contentyes, addressable by seqa chat line, a file, a call summary
Signalling stanzasnegotiation between partiesnoring, cancel, decline, join
Ephemeral framesa hint about right nownotyping, presence

This is not taxonomy for its own sake. Signalling used to travel as messages, which put routing and identity inside a body a client wrote — and a client could then ring somebody while appearing to be a colleague. The surfaces differ in who may state what.

6. Messages​

POST /v1/conversations/{id}/messages

{ "kind": "text", "body": "hello", "client_msg_id": "…" }

Kinds are an allowlist: text, media, location, contact, call, system. Anything else is 400.

  • system is written by the server. A client posting one gets 403, with a single exception admitted by shape: the call summary, which carries an outcome, a duration and flags — and no identity.
  • call messages are summaries only. A call step posted here is 410: steps are signalling. The two are told apart by shape — a step has an action, a summary has an outcome.

The server stamps sender, seq and timestamps. A client must take authorship from the envelope, never from the body. client_msg_id makes a resend idempotent, which is what lets a client retry without double-posting.

7. Signalling stanzas​

POST /v1/signal

{ "to": "i.ivanov", "kind": "call", "step": "invite",
"call": "c-7f3a", "seq": 1, "body": { "video": true, "room": "conv-42" } }
FieldWho states itNotes
toclient, validateda conversation the sender belongs to, or a user they share one with
kindclient, allowlistedcall, webrtc, sub; an unknown family is 400
stepclientcall: invite, cancel, join, end, decline, hold, transfer; webrtc: offer, answer, candidate, camera; sub: subscribe, unsubscribe
call, seqclientthe attempt's id and the order of steps within it

The sender is stamped by the service. A stanza is not persisted: it is delivered to whoever is connected now and never becomes a line in the conversation.

8. Socket events​

One connection. Every event is {"type", "conv?", "seq?", "ts", "data", "rev?", "rev_count?"}; ts is unix seconds, like every other timestamp on the wire.

TypeData
message.new / message.edit / message.deletethe message
receipt{user, conversation_id, seq, state} — delivered or read, a watermark: it applies to everything up to seq
signalthe stanza (§7)
typing{user} — never the reader themselves
uploading{user, file, progress} or {user, file, done: true} — somebody is sending a file into the room (§9); never the sender themselves
presencethe effective presence of one user
inbox, conversation.updatedthe conversation, with its members
reaction, pin, membership, unit, draft, avatar, purge, group, conversation.deletedas named
calendar.changed{calendar_id, sync_seq} — a calendar's cursor moved; the client pulls the changes from the calendar service
directory.changed{user, rev} — somebody appeared, changed, was blocked or left; scope is applied on the pull
guest.recovery.requested{} — a guest cannot sign in; addressed to their sponsor and deliberately carries nothing else

A conversation delivered as an event carries its members — always for direct and secret chats, and for rooms while membership is at most sixteen. Unread counters, pins and mute state are not included: those are answers to "who is asking", and an event goes to every member at once.

9. Ephemeral frames​

The only things a client puts on the socket:

{ "kind": "typing", "conv": "c-42" }
{ "kind": "presence", "awake": false }
{ "kind": "uploading", "conv": "c-42", "file": "photo", "progress": 42 }

Both fields are stated and both are validated; kind is allowlisted. There is no field for the person — the service stamps that from the socket it authenticated. The key type is not accepted on an inbound frame: that is the server's outbound vocabulary, and accepting it back keeps alive exactly the confusion this rule removes.

There are no compatibility aliases

A protocol cannot carry a permanent "we also accept the old thing" clause: that is a protocol with two shapes, because "the last old client is gone" is never provable. So the old shape is refused, the refusals are counted in /healthz, and the price is stated in the release notes. A call step sent the old way gets 410 — and there it is not even a trade-off: an old client does not listen for signal events, so accepting one would compose an invite that reaches nobody.

Typing is delivered to the room's other members and never echoed to the sender.

"Sending a photo… 42 %". The uploading frame (since 12.10.2026): file is one of photo, video, audio, voice, file; progress is 0–100, and done: true ends it (sent, or given up). The other members receive an uploading event with user stamped by the service. The service passes at most one progress frame a second per person and conversation (the first frame and done always pass); a sender repeats the frame at least every 5 s, and a reader drops an indicator not repeated for 6 s. Never stored and never echoed, like typing. Not a protocol bump: the frame and the event are both additions.

10. Presence​

A person's presence is not a property of a connection. A suspended app holds a socket, so "connected" and "available" are different things, and the client states its own with the presence frame (awake). The push decision rests on the same distinction: a message counts as delivered only when the app is both connected and awake.

11. Names and avatars​

The wire carries an identifier; the display name is resolved by the reader from its own directory cache. Practical consequence: what arrives in your integration is a username, not "Ivan Petrov" — and a telephone number resolves the same way.

12. Capabilities: what this caller may do​

The client asks what it may offer; the server decides what it will accept. The first answer is for drawing a screen, the second is the only one that matters.

Two scopes: the account as a whole (GET /v1/me/capabilities) and rights inside a conversation. Three rules are binding on any client:

  1. An unknown capability name means not granted. That is what makes the vocabulary extensible in the safe direction: a new permission gates a new control in a new client, while an older one shows nothing.
  2. A 404 is not the empty set — it is a deployment older than this section.
  3. Advice is not permission. Limiting the interface does not replace the server's check; the server checks regardless.

13. Time on the wire​

Every timestamp is an RFC3339 instant, and every service stamps it in UTC. That is the whole rule; the rest is what breaking it costs.

A reader must accept every form serialisation produces: …T00:00:00Z and …T00:00:00.881243881Z. A reader that accepts one of them is the bug: it does not fail loudly, it returns nothing, and a date turns into a blank on the screen. A client holds an instant, never its spelling — a string sorts lexicographically, which matches chronology exactly until the first offset arrives.

Socket (ts) and token (exp, iat, rnw) fields are unix seconds.

14. Extending this​

Adding a field, ask: could a client write it? If yes, and something reads it as an identity or an authority, it does not belong in the protocol — it goes in the envelope, where the service stamps it.

Adding a client→server shape, ask which of the three surfaces it is: negotiation is a stanza, a hint about right now is a frame, content is a message.

Adding a kind, family or frame type — put it on the allowlist. A shape that is delivered and ignored is indistinguishable, to its sender, from one that was lost.

15. Guests, and getting one back in​

A guest is an account somebody here invited: a phone number, an expiry, and the colleague who is answerable for them. Guests lose their passwords routinely, so recovery works like this:

CallWhoWhat it does
POST /v1/auth/recover {phone}anybody"I cannot get in." Answers {status:"ok"} whatever happens — the same body for a known number and an unknown one, because a different answer would make this a way to ask whether an account exists
POST /v1/auth/recover/confirm {phone, code, new_password}anybodyexchanges the code for a password the guest chooses. Ten minutes, five attempts, single use, hashed at rest

The five attempts belong to the phone number, not to the code: re-requesting buys no guesses.

Downloading, resuming, and what a file looks like before it arrives​

GET /v1/uploads/{id} (and ?thumb=1) is a plain HTTP resource: Range → 206 with Content-Range and Accept-Ranges: bytes; a strong ETag (the file's and its thumbnail's differ); If-Range with it answers 206 for the same file and 200 with the whole body for a different one — never a slice of something else; If-None-Match answers 304. An attachment's bytes never change (a new file is a new id), so Cache-Control says immutable.

The attachment's metadata carries what a client draws before the first byte: width and height (after EXIF rotation), has_thumb (a thumbnail of at most 512 px at ?thumb=1) and preview — base64 of a JPEG of at most 32 px and 1 KB, drawn at once, scaled up and blurred. Drive files (…/nodes/{id}/content, ETag is the content's sha256) answer the same way, and their nodes carry the same four fields, with the thumbnail at …/nodes/{id}/thumb.

What this document does not cover​

The coordinator's own API (access policy, gateways, meetings) and the directory's sign-in are separate surfaces with the same rules, described in the endpoint reference. A conversation call's pass is this service's POST /v1/calls/token; the coordinator has no such door. Federation between installations is described from the administrator's side and its wire is not published while there is only one implementation of it.