Build it

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.

Not everything is replayable. Catch-up is served from the message history, so membership changes, receipts, reactions, call events and broadcasts (all 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.updated event 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.