Real-time protocol (WebSocket)

How to build a real-time chat client without the JS SDK. iOS (Swift), Android (Kotlin), Flutter (Dart), Python, Go, or any language that can do WebSocket, JSON and HTTP can do the same work. Implement the rules on this page and your client behaves like the official SDK. The checklist "Building a client without the SDK" near the end lists everything.

  • Events the server sends (new messages, reactions, typing, who is online…) arrive over WebSocket.
  • History, filling the gap after a disconnect, and unread counts come from REST. Neither replaces the other. A subscription doesn't replay what already happened, and REST doesn't push what happens next.
  • Sending a message works over both. Reactions, read positions and typing notices work over WebSocket only.

At a glance

  1. Get a user token (JWT) from your backend. Signing with the sk_ is the backend's job (Server integration).
  2. Open a WebSocket to wss://api.chat.gravex.app/v1/ws?v=1.
  3. Send auth as the first frame. If it isn't sent within 5 seconds you are disconnected.
  4. When hello arrives you are ready. Send a ping every 25 seconds.
  5. Send subscribe for each room you are showing, and fill the difference between the lastSeq you get back and the last seq you hold using REST history.
  6. Apply the events that arrive as push frames to your screen state.
  7. When the connection drops, reconnect with backoff and repeat from step 5.

Every frame is one JSON text frame. Binary frames are not used.

Connecting

wss://api.chat.gravex.app/v1/ws?v=1
  • ?v=1 is the protocol version. If it is missing or different, the handshake is refused with HTTP 400.
  • The key and token go in the first frame, not the URL or an HTTP header (URLs end up in logs).
  • A browser's Origin must be in the pk_'s allowed origins. A native client that sends no Origin header skips this check.
  • While the server is being replaced the handshake answers HTTP 503. Retry with backoff.
  • Compression extensions and subprotocols are not used.

Frame format

Every frame is this envelope. id and data may be absent.

json
{ "type": "publish", "id": "c7", "data": { } }

Frames the client sends

  • type is one of the 8 below. Anything else is invalid.
  • Always attach an id. Any string that is unique within the connection will do ("c1", "c2"…).
  • The server answers with the same id and exactly one of ack or error. That is how requests and responses are paired. You may send several frames in a row; replies are not guaranteed to come back in order.
  • An id is not an idempotency key. The only dedup key is publish's clientMessageId.
  • The server ignores fields it doesn't know.

Frames the server sends

type Shape Meaning
hello {data: {…}} Authentication succeeded
ack {id, data} The request with that id succeeded. data depends on the request
error {id?, data: {code, message, retryAfterMs?, appCode?}} The request with that id failed. No id means a connection-level error (the frame was too big or not JSON, so no id could be read)
push {data: {roomId?, event, data}} An event the server starts. Room events have a roomId; user events don't
pong {} The reply to ping
reconnect {data: {reason, delayMs}} The server is about to go down. Reconnect after delayMs
  • The protocol only adds fields. Ignore fields you don't know and push events you don't know (don't treat them as errors).
  • A frame is at most 64 KB by default. Over that is too_large, and the connection stays up. After hello the ceiling is hello.limits.frameSizeBytes.
  • Error codes are the same as REST: unauthorized, forbidden, not_found, invalid, too_large, rate_limited, banned, moderation_denied, cursor_too_old, quota_exceeded, internal. They mean what the table in the REST API says. A failed frame does not close the connection (only the authentication step is an exception).

Authentication: auth then hello

Send this as the first frame within 5 seconds of opening.

json
{ "type": "auth", "id": "auth", "data": { "key": "pk_…", "token": "<user token JWT>" } }

On success a hello comes back on the same connection.

json
{
  "type": "hello",
  "data": {
    "connectionId": "0AAS4P1MM0001",
    "user": { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png" },
    "mode": "token",
    "serverTime": 1792861844000,
    "heartbeatMs": 25000,
    "limits": { "frameSizeBytes": 65536, "bodyTextMaxLen": 4000, "publishPerSecond": 5, "…": 0 },
    "unread": { "total": 3, "mentions": 1 },
    "warnings": []
  }
}
Field Notes
connectionId This connection's id. Used for support and to target a forced disconnect
user Me, as the server sees it. id is the token's sub. Messages go out with this as their sender
mode Always "token"
serverTime Server time (epoch milliseconds). Use it to correct a wrong device clock
heartbeatMs How often to send ping, in milliseconds
limits The effective limits for this app. See Limits
unread The unread total across every room I am a member of: {total, mentions, roomsCapped?}. It is not a per-room list. The field may be absent, and absent is not 0 — if it is missing, ask GET /v1/me/unread. Don't assume 0 and clear the badge
warnings An array of strings. Usually empty

When authentication fails

An error comes first (its id is the auth frame's id) and then the server closes the connection. The close reason equals the error code.

code Cause What to do
unauthorized The key or token is wrong, expired or revoked; the Origin isn't allowed; the first frame wasn't auth; no auth within 5 seconds Don't retry with the same token. If you can get a new token from your backend, do and try once more; if not, stop and tell the user
forbidden This key isn't allowed Stop
invalid The auth data isn't JSON Fix your code
rate_limited The connect limit (10 per minute per user, 60 per minute per key and IP) or the concurrent-connection limit Wait retryAfterMs if present, otherwise back off, then reconnect
internal Server error Reconnect with backoff

Get the token from your backend on every connect. Don't fetch it once and reuse it. A token expires within 24 hours, and a connection that is already open stays open regardless of expiry.

Keeping the connection alive

  • Send {"type":"ping","id":"p1"} every heartbeatMs (25 seconds). The server answers {"type":"pong"}. If no client frame arrives for 60 seconds the server closes the connection. Send this JSON ping; a WebSocket library's own ping control frame is not a substitute.
  • When a mobile app returns from the background or the network changes, send a ping right away. If there is no answer the socket is dead, so reconnect immediately.
  • When the server is going down for a deploy it first sends {"type":"reconnect","data":{"reason":"shutdown","delayMs":3200}}. Wait delayMs and then connect again (the server spread those times out so clients don't arrive all at once).
  • If you read the events too slowly the server closes the connection (reason slow_consumer). Don't do slow work in the read loop. Reconnect and fill the gap.

Close reasons

A connection the server closes uses WebSocket close code 1008 (rarely 1000) with one of these reason strings.

Reason Meaning Client
unauthorized, forbidden, invalid, rate_limited, internal, auth timeout Refused during authentication The table above
disconnected (or the wording your service chose) Your service force-closed this user's connections You may reconnect with the token you hold
tokens_revoked All of this user's tokens were invalidated Get a new token from your backend. Reconnecting with the same one comes back as unauthorized
app_deleted The app was deleted Reconnecting becomes unauthorized. Stop
slow_consumer The events were read too slowly Reconnect and fill the gap

If your library doesn't expose the reason, the rule is the same: reconnect when closed, and stop if authentication answers unauthorized, forbidden or invalid.

Watching a room: subscribe and unsubscribe

Watching (a subscription) means "this connection receives every event of that room." Subscribe only to the rooms that are open on screen right now (maxSubscriptions per connection, 100 by default).

json
{ "type": "subscribe", "id": "c2", "data": { "key": "lobby", "since": 12 } }
Field Notes
roomId or key Exactly one. Even if you open a room by key, every later push carries only roomId. Remember the roomId from the ack
since The top seq of the unbroken run of messages you hold. Leave it out for a room you haven't seen

The answer:

json
{
  "type": "ack", "id": "c2",
  "data": {
    "roomId": "0AAS4P1MM0000", "lastSeq": 15, "minSeq": 1,
    "presence": { "count": 2, "users": [{ "id": "u-1", "name": "alice" }, { "id": "u-2", "name": "bob" }] }
  }
}
ack field Notes
lastSeq The room's last seq. If what you hold is lower, there is a gap
minSeq The oldest seq history can be read from
presence Who is in the room now: {count, users?, capped?}. users is present only when the room has 100 people or fewer. If the lookup fails the field is missing altogether (different from 0 people)
  • Subscribing does not deliver past events. Fetch history over REST (Reconnecting and filling gaps).
  • If since is lower than minSeq - 1 you get a cursor_too_old error. You can't attach, so drop what you hold, subscribe again without since, and load recent history.
  • Subscribing again to a room you already watch changes nothing and returns the same ack.
  • A subscription belongs to the connection. When the socket drops they all vanish, so send them again after hello.
  • The moment you subscribe, the user joins the room's presence list. It leaves when you unsubscribe or the connection drops.
  • A room you can't subscribe to is an error: missing or another app's room not_found, a room you can't read forbidden, over the limit too_large, a withdrawn user unauthorized.
  • publish, react, read and typing can be sent to a room you haven't subscribed to (permission is all you need). You only receive that room's events if you subscribe.
json
{ "type": "unsubscribe", "id": "c3", "data": { "roomId": "0AAS4P1MM0000" } }

unsubscribe always succeeds with an empty ack ({}), even for a room you weren't watching.

A new message in a room you are not watching arrives, if you are a member, as the user event notification.message (for unread badges). That room's message.created does not arrive. On one connection you get one or the other.

Sending a message: publish

json
{
  "type": "publish", "id": "c4",
  "data": {
    "roomId": "0AAS4P1MM0000", "clientMessageId": "cm-6f1c9a1e",
    "body": { "text": "hi @bob", "entities": [{ "type": "mention", "userId": "u-2", "offset": 3, "length": 4 }] },
    "replyTo": "0AAS4P1P40000", "threadId": "0AAS4P1P40000", "meta": { "client": "ios" }
  }
}
Field Required Notes
roomId yes
clientMessageId yes See "Idempotency" below
body yes {text?, attachments?, entities?}. An attachment is {type, url, key?, mime?, w?, h?, name?, size?} and url must be https
replyTo The id of the message to quote. Must be in the same room
threadId For a thread reply, the original message's id. If you give a reply's id it is rewritten to that root
meta A JSON object
kind Leave it out (text). system is sent only with a server key through REST

The ack: {"messageId": "0AAS4P1P40800", "seq": 16, "createdAt": 1792861844902}

  • A message you got an ack for is stored. If you watch the room, your own message also comes back as message.created. The ack and message.created arrive in no fixed order, so use id or seq to drop the duplicate and draw it once.
  • Drawing the message on screen at the moment of sending (an optimistic display) is not recommended. The app's before_publish hook can change or refuse the text, so the stored form is final only after the ack.
  • Failure codes: forbidden (a room you can't write to, a plain member of a channel), banned, moderation_denied (your wording is in message and data.appCode), rate_limited (retryAfterMs), too_large (text over bodyTextMaxLen), invalid (no clientMessageId, an attachment url that isn't https, more than 100 entities), quota_exceeded (the app's storage quota), not_found.
  • The engine does not escape the text. A client that renders HTML or markdown escapes at render time. The same goes for display names.
  • A mention in entities counts only if userId is a real user. An unknown id is left out rather than rejected, and the entity stays in the text.

Idempotency: clientMessageId

  • Make a new value for every message (UUIDv4 recommended, e.g. "cm-" + uuid).
  • If the same sender sends the same value again within 24 hours, no new message is made and the ack carries the messageId and seq of the one stored first.
  • If the connection drops before the ack and you don't know whether it was stored, send again with the same clientMessageId. A new value would post the same words twice.
  • When the user taps "send again", keep the value. New content gets a new value.

Reactions: react

json
{ "type": "react", "id": "c5", "data": { "roomId": "0AAS4P1MM0000", "messageId": "0AAS4P1P40800", "emoji": "👍", "op": "add" } }
  • op is "add" or "remove". There is no default. If it is missing or anything else it is invalid.
  • It is idempotent. Sending add for an emoji you already pressed is not an error.
  • emoji is an opaque string the app chooses, at most 64 bytes (bytes, not characters).
  • A message can have up to maxDistinctEmojiPerMessage (20 by default) different emoji. Pressing an emoji that is already on the message is exempt from this limit. Going over it fails with invalid. To block it in the UI first, compare the row's reactions length with limits from hello.
  • The ack: {"messageId": "…", "emoji": "👍", "count": 3, "changed": true}. count is the value after your press, and changed: false means nothing changed. Even if you missed the broadcast, use this count to catch up.
  • A deleted message gets not_found. A banned user gets banned.

Read position: read

json
{ "type": "read", "id": "c6", "data": { "roomId": "0AAS4P1MM0000", "seq": 16 } }
  • seq is 1 or more. Send the seq of the last message you have seen.
  • The position only moves forward. A value lower than the current one is ignored without an error. A value past the room's lastSeq is cut down to lastSeq.
  • The ack: {"roomId": "…", "seq": 16, "advanced": true}. These are the values the server really wrote. advanced: false means it was ignored (a value going backwards) or there was nothing to store (a room with no messages).
  • With threadId it is that thread's position (a reply's id becomes its root). The ack's threadId is the root it landed on. Without it, it is the main timeline, and reading a thread does not clear the room badge.
  • To load my own positions again, use GET /v1/rooms/{roomId}/reads?userId=<my id>.
  • The server counts unread. Don't compute it as lastSeq - readSeq (thread replies, my own messages and deleted messages make that wrong).

Typing: typing

json
{ "type": "typing", "id": "c7", "data": { "roomId": "0AAS4P1MM0000", "threadId": "0AAS4P1P40000" } }

Give threadId only while writing a thread reply.

Sending side:

  • Call it on every input event, but send at most once per second per room (cut it down in the client). Skipping this wastes frames.
  • The server broadcasts at most once per 3 seconds for one user in one room. A frame that gets cut still gets a normal ack, with empty data.
  • The room also caps how many people it announces per window (3 seconds), so in a busy room some typists are never announced.
  • Write permission is required. A plain member of a channel can read but gets forbidden. A banned or withdrawn user's typing is silently ignored.
  • You don't have to wait for the typing ack. A failure shouldn't block typing.

Receiving side (the typing event):

  • There is no "stopped typing" event. Show it for at least 5 seconds after the last typing event, then clear it.
  • Your own typing comes back to you. Skip it when the event's userId is your id.
  • The event has no name. Look it up in the room's online list (presence.users) or in a recent message's sender.
  • With a threadId it is typing in that thread (a root message id); without it, the main timeline.

Events the server sends (push)

json
{ "type": "push", "data": { "roomId": "0AAS4P1MM0000", "event": "message.created", "data": { } } }

Room events arrive while you are watching that room. User events (notification.message, user.presence) arrive regardless of subscriptions. A room event's roomId tells you which room.

event Scope data
message.created room A message object. Thread replies are this event too. thread is absent, reactions is an empty array and myReactions is absent (it is brand new)
message.updated room A message object. For an edited message reactions and thread are real values. myReactions is absent
message.deleted room {id, seq}. seq is the value from when it was published
reaction.added, reaction.removed room {messageId, emoji, count, userId}. count is the value after the change
thread.updated room {rootId, count, lastSeq, lastAt}. When a reply is added or deleted. On a delete it comes after message.deleted
presence room {count, joined?, left?, users?}. See "Who is online" below
typing room {userId, threadId?}. See "Typing" above
read room {userId, seq, threadId?}. Someone's read position moved forward
member.joined, member.left room {userId, role?}
room.updated room A room object
room.deleted room {roomId}. After this frame your subscription to that room is gone. The connection stays
custom room The JSON object your service backend sent with POST …/custom, unchanged
notification.message user {roomId, seq, unread?, unreadMentions?, unreadCapped?, preview?}. A new message arrived in a room where I am a member but not watching. unread is my unread count including this message; if the calculation fails the field is missing (it isn't 0). preview is {sender: {id, name, avatar?}, text, createdAt}
user.presence user {userId, status: "online" | "offline", lastSeenAt?}. The state of the other person in a dm or private room I am a member of. It comes only when that user's connection count goes from 0 to 1 or from 1 to 0. lastSeenAt is present only for offline
  • Message events (message.*) are recovered from REST history after a reconnect if you missed them. The transient events (presence, typing, custom, notification.message, user.presence) may be missed and are not recovered. Missed reaction, thread-count, read and membership changes are only corrected through the aggregates on the messages in the history you load.
  • If you missed notification.message, read GET /v1/me/rooms again for per-room unread counts.

Rules for applying events to screen state

Event Rule
message.created Insert it at its seq. If that seq already exists, overwrite it (but don't revive one you marked deleted). If its seq is not your top seq + 1, there is a gap → fill the gap
message.updated Replace that id's fields with the new values. Ignore it if the row is already deleted
message.deleted Don't remove the row. Empty its body and mark it deleted (keep the seq slot). If you don't hold the row yet, remember the id and empty it when the row arrives
reaction.added, reaction.removed Set that emoji's count to count (don't add or subtract). A count of 0 or less removes the emoji. If userId is my id, add or remove it in my myReactions
thread.updated Set the root message's thread to {count, lastSeq}
room.deleted Close that room
  • Draw only messages without a threadId on the main timeline. Thread replies go in the thread view.
  • The sync position (seq) is about the room's whole stream. Even if you draw only the main timeline, use every message including thread replies when computing the position.

Who is online (presence)

A room's presence is the users with a connection subscribed to that room. The first value comes from the subscribe ack's presence, and later you update it from presence events.

  • A presence event's joined and left are increments. Add joined to the list you hold (replace any entry with the same id) and remove left (an array of user ids).
  • When users arrives, replace the whole list with it.
  • joined/left and users never arrive in the same frame.
  • A room of 100 people or fewer gets joined/left immediately as people come and go. A larger room gets, every 5 seconds, only count, and no joined or users. The room goes into that mode at 100 and comes out at 90.
  • If an event carries only count and none of joined, left or users, the list you hold can no longer be trusted. Drop the list and use only count. For the whole list, call GET /v1/rooms/{roomId}/presence?full=true (cut off at 1000).
  • When the room returns to a small one, a {count, users: everyone} snapshot arrives once. That repairs the stale list.
  • When you hold no list, don't build one from joined alone (it would show only the people who arrived after you).
  • A person may open several connections and still appears as one line. joined comes at that user's first subscription and left when the last one is released.

Reconnecting and filling gaps

Disconnects are normal. A subway, the background and a deploy are all disconnects. However many events you missed, seq tells you. A room's seq goes up from 1 with no gaps, so if the numbers you hold are not contiguous, you fetch what lies between them over REST.

The reconnect loop

attempt = 0
loop:
  token = fetch a fresh token from your backend      # on every connect; if that fails, back off and retry
  open the ws, send auth
  on hello: attempt = 0, start the ping timer, run resubscribe below
  when the ws closes (or dies without an ack):
    fail every pending request (see "Resending" below)
    if authentication was refused (unauthorized/forbidden/invalid) stop
    wait = the value the server gave (reconnect.delayMs, rate_limited's retryAfterMs) if there is one
           otherwise 500ms + random() × (min(30000, 500ms × 2^attempt) − 500ms)   # exponential backoff + jitter
    attempt += 1, wait, then loop
  • Don't drop the jitter. When a server goes down, every client is cut at the same instant and comes back at the same instant.
  • A send the user pressed may skip the backoff and try to reconnect at once. A wait the server gave is not skipped.
  • If hello's user.id differs from the previous connection's (a different account after logging out), throw away all room state and load it fresh. That keeps the previous user's myReactions off the new user's screen.

Resubscribe (each time you get hello, the first time too)

Do this per room. A room's state is messages (a list sorted by seq) and contiguousSeq.

contiguousSeq = the top value of the run, starting at the first message, where seq goes +1 (or stays equal). 0 if the list is empty

send subscribe { roomId or key, since: contiguousSeq (leave out if 0) }
  ├─ cursor_too_old error → drop the list, subscribe again without since → load recent history (A below) → tell the UI "the list was rebuilt"
  └─ ack {lastSeq, minSeq}
       ├─ since was sent (you hold something) → catchUp(lastSeq) (B below)
       └─ no since → if lastSeq > 0, load recent history (A)

A. Recent history: one GET /v1/rooms/{roomId}/messages?limit=100. Replace the list with the page, but don't delete messages that arrived by push during the request and whose seq is above the page's top seq.

B. catchUp(upTo): fill from your contiguousSeq up to upTo.

fill(after, before):               # the range after `after` and before `before`, both excluded
  cursor = after
  while cursor < before - 1:
    page = GET /v1/rooms/{roomId}/messages?after={cursor}&limit=100     # leave out view (whole room)
    stop if the page is empty
    insert every message of the page at its seq
    stop if the page's top seq <= cursor
    cursor = the page's top seq

catchUp(upTo): fill(contiguousSeq, upTo + 1)

after=N returns up to 100 messages with seq > N, oldest first. If it doesn't all arrive at once, pass the last seq as after and continue. If you are already contiguous (upTo <= contiguousSeq), don't call REST at all.

Gaps during live use

If a message.created's seq is more than your top seq + 1 (you hold up to 15 and 18 arrives), insert 18 first and then fill(15, 18) to get 16 and 17. If the list is empty, skip the gap check.

Scrolling up (older history)

If your top seq is above minSeq, older messages exist. Fetch them with GET …/messages?before={your top seq}&limit=100 and put them in front. A response shorter than limit means there is no more.

Pitfalls

  • The results of view=main, thread:… and q (search) have non-contiguous seqs. Don't use their last seq as a sync position. Always sync without view (whole room).
  • A deleted message still has a row with its seq (a tombstone). Deleting never leaves a hole in seq.
  • A 401 from REST may mean the token expired. Retry once with a new token. The socket authenticates only at connect, so you can have a healthy socket while only REST gets 401.
  • Missed reactions, thread counts, read positions and membership changes are corrected only for messages that are in the history page. After a long time away, the honest fix is to load recent history again for the range on screen.

Resending

A request whose connection dropped before its ack is one the server may or may not have received. Handle it like this.

Request May you send it again?
publish Send it again with the same clientMessageId. It won't duplicate
react Send it again (idempotent)
read Send it again (idempotent)
typing Don't resend (transient)
subscribe Resubscribing on the new connection takes its place

One resend is enough. If the connection doesn't come back, tell the user it failed after a timeout (15 seconds recommended).

Limits

Limits are per app, and the effective values for your app are in hello.limits. Don't hard-code the numbers below; read hello.limits. A key that is missing has its default.

limits key Default When hit
frameSizeBytes 65536 too_large (frame)
bodyTextMaxLen 4000 too_large (text length)
maxAttachments 10 invalid
publishPerSecond / publishBurst 5 / 10 rate_limited (per user; a burst of 10 plus an average of 5 over 10 seconds)
reactPerSecond 10 rate_limited
typingPerSecond 20 rate_limited
editPerSecond 2 rate_limited (REST edits)
connectPerMinute 10 rate_limited on connect (per user)
connectionEstablishPerMinute 60 rate_limited on connect (same pk_ and same IP)
maxConcurrentConnections 10 rate_limited on connect. There is no retryAfterMs (it can't know when a slot frees up)
maxSubscriptions 100 subscribe gets too_large
roomCreatePerDay 50 rate_limited (creating rooms with a user token)
maxDistinctEmojiPerMessage 20 invalid
notificationMemberCap 1000 A room with more members doesn't send notification.message. Read GET /v1/me/rooms periodically
editWindowMs 0 (no limit) The time allowed for editing
maxStoredMessages absent (unlimited) The app's storage quota. When it is full, quota_exceeded
  • hello.limits may hold keys not in this table. Ignore keys you don't know.
  • hello.limits is the value at the moment you connected. If limits change, an open connection isn't told again. The next connect's hello has them.
  • Limits are counted in fixed windows (not sliding). At a window edge you can briefly get twice as much.
  • Wait retryAfterMs from a rate_limited error and then send the same request again. Don't shorten it and retry sooner.
  • A server key's REST calls don't count against the publish and edit limits.

Building a client without the SDK

The complete list of what the official SDK (JS/TS) does for you. Implement this list and you have the same client as the SDK. Build it top to bottom.

Connection

  1. Have a function that gets a user token from your backend. Call it on every connect, and whenever REST returns 401.
  2. Connect to wss://api.chat.gravex.app/v1/ws?v=1 and send auth when it opens. After hello you are open. Keep a state: connecting, open, reconnecting, (closed).
  3. Make frame ids, and keep a table of requests waiting for a reply by id. An ack resolves it, an error fails it. A timeout (15 seconds) and a dropped connection fail the whole table.
  4. Send ping every heartbeatMs. When the app comes back to the foreground or the network returns, send a ping at once and reconnect if it fails.
  5. On a drop, reconnect with exponential backoff (500ms to 30s) and jitter. Prefer reconnect.delayMs and rate_limited.retryAfterMs when present. Stop and tell the user on an authentication-step unauthorized, forbidden or invalid.
  6. If hello.user.id changes, throw away all room state.

Rooms

  1. Keep per room the messages (a list in seq order) and contiguousSeq.
  2. Each time you get hello, send subscribe (since = contiguousSeq) again for every room you are watching.
  3. On cursor_too_old, drop the list, subscribe again without since, and load recent history.
  4. Fill the gap up to the ack's lastSeq using REST GET …/messages?after=. Fill a gap that a live message.created reveals the same way.
  5. Fetch older history for scrolling up with before=.
  6. Apply events to the list by the "Rules for applying events" above. Don't remove a deleted row, empty it. Set reaction counts to the server's count.
  7. Merge presence as increments (joined/left) and replacements (users), and drop the list when only count arrives.

Sending

  1. Make a new clientMessageId for every publish. If it drops before the ack, send it again with the same value once.
  2. Resend react and read if they drop before the ack. Don't resend typing.
  3. Send typing at most once per second per room, and on the receiving side show it for at least 5 seconds after the last event, then clear it. Skip your own userId.
  4. Send read only forward, with the last seq that was really visible on screen. Don't send it too often.

Unread and notifications

  1. Right after connecting, use hello.unread for the badge if it is there, and if it is absent ask GET /v1/me/unread.
  2. For rooms you aren't watching, raise the room-list badge from notification.message's unread, and if you may have missed some, read GET /v1/me/rooms again.

Finishing

  1. Branch on error code (wait on rate_limited, show banned and moderation_denied to the user, don't retry quota_exceeded or forbidden).
  2. Escape message text and names when you draw them. The engine does not.
  3. (Optional) When the user presses a reaction, change my myReactions first and roll back if the ack fails. Use only the server's value for counts.

Push notifications are not this connection's job. For people whose app is closed, the message.created webhook reaches your service server, which sends through FCM or APNs.

Example frame conversation

→ is what the client sent and ← is what the server sent. One line is one frame.

→ {"type":"auth","id":"auth","data":{"key":"pk_…","token":"eyJ…"}}
← {"type":"hello","data":{"connectionId":"0AAS4P1MM0001","user":{"id":"u-1","name":"alice"},"mode":"token","serverTime":1792861844000,"heartbeatMs":25000,"limits":{"frameSizeBytes":65536,"bodyTextMaxLen":4000},"unread":{"total":0,"mentions":0},"warnings":[]}}
→ {"type":"subscribe","id":"c1","data":{"key":"lobby"}}
← {"type":"ack","id":"c1","data":{"roomId":"0AAS4P1MM0000","lastSeq":15,"minSeq":1,"presence":{"count":1,"users":[{"id":"u-1","name":"alice"}]}}}
   (then over REST: GET /v1/rooms/0AAS4P1MM0000/messages?limit=100)
→ {"type":"publish","id":"c2","data":{"roomId":"0AAS4P1MM0000","clientMessageId":"cm-6f1c9a1e","body":{"text":"hello"}}}
← {"type":"ack","id":"c2","data":{"messageId":"0AAS4P1P40800","seq":16,"createdAt":1792861844902}}
← {"type":"push","data":{"roomId":"0AAS4P1MM0000","event":"message.created","data":{"id":"0AAS4P1P40800","seq":16,"kind":"text","sender":{"id":"u-1","name":"alice"},"body":{"text":"hello"},"mentions":[],"reactions":[],"createdAt":1792861844902}}}
→ {"type":"react","id":"c3","data":{"roomId":"0AAS4P1MM0000","messageId":"0AAS4P1P40800","emoji":"👍","op":"add"}}
← {"type":"ack","id":"c3","data":{"messageId":"0AAS4P1P40800","emoji":"👍","count":1,"changed":true}}
← {"type":"push","data":{"roomId":"0AAS4P1MM0000","event":"reaction.added","data":{"messageId":"0AAS4P1P40800","emoji":"👍","count":1,"userId":"u-1"}}}
→ {"type":"publish","id":"c4","data":{"roomId":"0AAS4P1MM0000","clientMessageId":"cm-77aa","body":{"text":"quickly"}}}
← {"type":"error","id":"c4","data":{"code":"rate_limited","message":"rate limit exceeded","retryAfterMs":640}}
→ {"type":"ping","id":"p1"}
← {"type":"pong"}