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, broadcasts) 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.
Paging history
GET /v1/rooms/{room_id}/messages returns a page oldest first. Page
forward with after_seq — which is also how catch-up works. Scroll
up with before_seq alone: the page is the limit messages just
before it, so pass the first message's event_seq as the next before_seq until
a page comes back short. With neither, the page starts at the room's first message.
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.
Mentions and forwarding #
{ "client_message_id": "local-7f3b", "kind": "text",
"body": { "text": "@bob can you look?", "mentions": ["bob"] } } body.mentions lists up to 50 principals, each an active member of the room (checked on
send; 400 otherwise), stored and delivered exactly as you sent it, so render
@names from it. A mentioned member is pushed even while they have muted the
room — a mute silences the room, not someone addressing you. Not on system or
mls.* messages.
{ "client_message_id": "local-7f3c", "forward_of": "3f1e…" } // and nothing else
# → the copy, with "forward_depth": 1 — kind, body and media copied by the server
To forward, name the message and send nothing else: the server copies the source's
kind, body and media from any room you are an active member of, so a
client cannot forge the "forwarded" label or what is behind it. Media arrives as ready attachments
of this room, downloadable with this room's token. The copy carries
forward_depth (1 for a forward of an original, 2 for a forward of a forward…); the
source is never named to recipients, and mentions do not travel. Readable kinds only
— text, image, voice, video, file,
story — never a deleted message, never into an E2EE room (409). Idempotent
like any send.
Typing, presence and broadcasts #
All three 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.
{ "v": 1, "request_id": "…", "type": "broadcast",
"payload": { "body": { "cursor": [120, 44] }, "to": "bob" } } // "to" optional
A broadcast is for what is wrong to store and wrong to replay: cursors, pointers, a
reaction in a call, a timer. Every member connected at that moment receives it as a
broadcast event with event_id: 0 and payload
{from, body, to?}, body value-for-value; with to,
only that member's sockets do. Your own sockets receive it too (ignore from === me if
unwanted). body is at most 1 KiB as compact JSON; a socket may send 20 a second,
with a burst of 40 absorbed. Nothing is acknowledged on success; a refusal
(payload_too_large, rate_limited, blocked,
forbidden, invalid_request) is an error ack and the socket stays open. If
it must survive a reconnect, it is a message, not a broadcast.
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.