Chat
Durable, strictly ordered messaging. Every message is committed and sequenced before anyone is told about it, which is what makes reconnection lossless.
Sending #
Over the socket, with an ack correlated by your request_id:
{
"v": 1,
"request_id": "0b0f2b1e-1f4a-4b6f-9a0f-2b1e1f4a4b6f",
"type": "message.send",
"payload": {
"client_message_id": "local-7f3a",
"kind": "text",
"body": { "text": "hello" },
"reply_to": null,
"attachment_ids": []
}
} kind defaults to text and may be image, voice,
video, file, story, system, or one of the
encrypted mls.* kinds in an E2EE room. body is free-form JSON — for text
the convention is {"text": "…"}.
client_message_id is your idempotency key. Retry with the same value
after a lost response and you get the original message back rather than a duplicate. This
solves the classic mobile "sent, lost the response, retried" problem server-side — so always
generate it before the first attempt and reuse it on retries.
Ordering and sequences #
Each durable event carries an event_id (the message's event_seq) that
increases monotonically within a room. It is not global — track a cursor per room.
Ephemeral events (typing, presence) carry 0 and are never replayed.
Catch-up and resync #
After any disconnect, re-open the socket and immediately request everything after your last processed sequence. Deliberately overlap by one — duplicates are cheap to discard, gaps are not.
{ "v": 1, "request_id": "…", "type": "catchup",
"payload": { "after_seq": 4130, "limit": 200 } }
If a client falls too far behind while connected, the server sends a resync event
rather than trying to stream the backlog. Treat it as: refetch recent history over REST, reset
your cursor, carry on.
event_id: 0) are
delivered live but not replayed. If your UI depends on current state after a long absence, refetch
it — participants via GET /v1/rooms/{id}/participants, unread via
GET /v1/rooms/{id}/unread.
Receipts and unread #
receipt.delivered— the device received it. Does not move the read cursor.receipt.read— the user actually saw it. Advances the per-member read cursor monotonically.
Unread is computed as messages after the cursor, excluding the member's own and deleted ones. The cursor is per member, not per device, so reading on a phone clears the badge on a laptop. On the directory path the same cursor powers the inbox.
Edits, deletes, reactions #
- Edit — author only; history preserves that a change happened, and an
message.updatedevent is emitted. - Delete — soft; the row remains but content is withheld and the kind becomes
deleted, so replies pointing at it do not break. - Reactions — emoji, at most 20 distinct per message, aggregated into message history so you do not need a second query to render them.
Typing and presence #
Both are ephemeral and never persisted. Presence is derived from expiring keys, so a client that loses power or network is detected as offline without needing a graceful disconnect — and push targeting depends on exactly that signal.
REST surface #
Every socket write has a REST equivalent, for server-side sends and socket-less clients:
POST /v1/rooms/{room_id}/messages # send
GET /v1/rooms/{room_id}/messages?after_seq=&before_seq=&limit=
PATCH /v1/rooms/{room_id}/messages/{message_id} # edit (author only)
DELETE /v1/rooms/{room_id}/messages/{message_id} # soft delete
GET /v1/rooms/{room_id}/messages/search?q= # full-text, this room only
GET /v1/rooms/{room_id}/unread # count + cursors
GET /v1/rooms/{room_id}/participants
Server-side sending as an arbitrary principal uses
POST /v1/server/rooms/{room_id}/messages with an ak_ key —
handy for bots and system messages.