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:
- The server stamps identity.
sender,fromanduserare 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. - The server validates addressing.
tois checked against membership, not trusted. - 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
| Base | https://<messaging host>/v1 |
| Auth | Authorization: Bearer <token> on every request, including the socket handshake |
| Bots | the same header with a bot_… token; bounded by capability scopes and a conversation allowlist, and every request is audited, reads included |
| Errors | JSON {"code": "<identifier>", "error": "<sentence>"} |
| Events | one WebSocket: GET /v1/ws?token=…&device=… |
Status codes carry meaning and clients must distinguish them:
| Code | Means | Client behaviour |
|---|---|---|
| 400 | the request is malformed or names something unknown | a bug: report it, do not retry |
| 401 | the token is absent, expired or revoked | renew and retry once |
| 403 | authenticated, and not allowed | do not retry: this is an answer, not a failure |
| 404 | no such thing, or none this caller may see | treat as absent |
| 409 | conflicting state (duplicate join, taken name) | resolve and retry |
| 410 | this shape is gone; the message names its replacement | fix the client |
| 429 | rate limited | back 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.
| Header | Direction | Means |
|---|---|---|
X-SimpleTwo-Protocol | server → client | what this service speaks |
X-SimpleTwo-Protocol-Min | server → client | the oldest client it still serves |
X-SimpleTwo-Client-Protocol | client → server | what 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 server | Server | Client |
|---|---|---|
| current | normal | nothing |
behind, ≥ Min | normal, counted per version | a non-blocking "update available" notice |
below Min | refuse: client_too_old | a blocking screen: this build cannot talk to this server |
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
| Surface | Carries | Persisted | Example |
|---|---|---|---|
| Messages | conversation content | yes, addressable by seq | a chat line, a file, a call summary |
| Signalling stanzas | negotiation between parties | no | ring, cancel, decline, join |
| Ephemeral frames | a hint about right now | no | typing, 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.
systemis 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.callmessages are summaries only. A call step posted here is 410: steps are signalling. The two are told apart by shape — a step has anaction, a summary has anoutcome.
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" } }
| Field | Who states it | Notes |
|---|---|---|
to | client, validated | a conversation the sender belongs to, or a user they share one with |
kind | client, allowlisted | call, webrtc, sub; an unknown family is 400 |
step | client | call: invite, cancel, join, end, decline, hold, transfer; webrtc: offer, answer, candidate, camera; sub: subscribe, unsubscribe |
call, seq | client | the 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.
| Type | Data |
|---|---|
message.new / message.edit / message.delete | the message |
receipt | {user, conversation_id, seq, state} — delivered or read, a watermark: it applies to everything up to seq |
signal | the 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 |
presence | the effective presence of one user |
inbox, conversation.updated | the conversation, with its members |
reaction, pin, membership, unit, draft, avatar, purge, group, conversation.deleted | as 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.
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:
- 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.
- A 404 is not the empty set — it is a deployment older than this section.
- 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:
| Call | Who | What 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} | anybody | exchanges 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.