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
- Get a user token (JWT) from your backend. Signing with the
sk_is the backend's job (Server integration). - Open a WebSocket to
wss://api.chat.gravex.app/v1/ws?v=1. - Send
authas the first frame. If it isn't sent within 5 seconds you are disconnected. - When
helloarrives you are ready. Send apingevery 25 seconds. - Send
subscribefor each room you are showing, and fill the difference between thelastSeqyou get back and the lastseqyou hold using REST history. - Apply the events that arrive as
pushframes to your screen state. - 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=1is 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
Originmust be in thepk_'s allowed origins. A native client that sends noOriginheader 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.
{ "type": "publish", "id": "c7", "data": { } }Frames the client sends
typeis one of the 8 below. Anything else isinvalid.- Always attach an
id. Any string that is unique within the connection will do ("c1","c2"…). - The server answers with the same
idand exactly one ofackorerror. 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
idis not an idempotency key. The only dedup key ispublish'sclientMessageId. - 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
pushevents 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. Afterhellothe ceiling ishello.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.
{ "type": "auth", "id": "auth", "data": { "key": "pk_…", "token": "<user token JWT>" } }On success a hello comes back on the same connection.
{
"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"}everyheartbeatMs(25 seconds). The server answers{"type":"pong"}. If no client frame arrives for 60 seconds the server closes the connection. Send this JSONping; 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
pingright 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}}. WaitdelayMsand 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).
{ "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:
{
"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
sinceis lower thanminSeq - 1you get acursor_too_olderror. You can't attach, so drop what you hold, subscribe again withoutsince, 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
unsubscribeor 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 readforbidden, over the limittoo_large, a withdrawn userunauthorized. publish,react,readandtypingcan 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.
{ "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
{
"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 andmessage.createdarrive in no fixed order, so useidorseqto 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_publishhook 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 achannel),banned,moderation_denied(your wording is inmessageanddata.appCode),rate_limited(retryAfterMs),too_large(text overbodyTextMaxLen),invalid(noclientMessageId, an attachmenturlthat isn't https, more than 100entities),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
entitiescounts only ifuserIdis 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
messageIdandseqof 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
{ "type": "react", "id": "c5", "data": { "roomId": "0AAS4P1MM0000", "messageId": "0AAS4P1P40800", "emoji": "👍", "op": "add" } }opis"add"or"remove". There is no default. If it is missing or anything else it isinvalid.- It is idempotent. Sending
addfor an emoji you already pressed is not an error. emojiis 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 withinvalid. To block it in the UI first, compare the row'sreactionslength withlimitsfromhello. - The ack:
{"messageId": "…", "emoji": "👍", "count": 3, "changed": true}.countis the value after your press, andchanged: falsemeans nothing changed. Even if you missed the broadcast, use thiscountto catch up. - A deleted message gets
not_found. A banned user getsbanned.
Read position: read
{ "type": "read", "id": "c6", "data": { "roomId": "0AAS4P1MM0000", "seq": 16 } }seqis 1 or more. Send theseqof 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
lastSeqis cut down tolastSeq. - The ack:
{"roomId": "…", "seq": 16, "advanced": true}. These are the values the server really wrote.advanced: falsemeans it was ignored (a value going backwards) or there was nothing to store (a room with no messages). - With
threadIdit is that thread's position (a reply's id becomes its root). The ack'sthreadIdis 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
{ "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 emptydata. - 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
channelcan read but getsforbidden. A banned or withdrawn user'stypingis silently ignored. - You don't have to wait for the
typingack. 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
typingevent, then clear it. - Your own
typingcomes back to you. Skip it when the event'suserIdis your id. - The event has no name. Look it up in the room's online list (
presence.users) or in a recent message'ssender. - With a
threadIdit is typing in that thread (a root message id); without it, the main timeline.
Events the server sends (push)
{ "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, readGET /v1/me/roomsagain 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
threadIdon 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
presenceevent'sjoinedandleftare increments. Addjoinedto the list you hold (replace any entry with the sameid) and removeleft(an array of user ids). - When
usersarrives, replace the whole list with it. joined/leftandusersnever arrive in the same frame.- A room of 100 people or fewer gets
joined/leftimmediately as people come and go. A larger room gets, every 5 seconds, onlycount, and nojoinedorusers. The room goes into that mode at 100 and comes out at 90. - If an event carries only
countand none ofjoined,leftorusers, the list you hold can no longer be trusted. Drop the list and use onlycount. For the whole list, callGET /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
joinedalone (it would show only the people who arrived after you). - A person may open several connections and still appears as one line.
joinedcomes at that user's first subscription andleftwhen 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'suser.iddiffers 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'smyReactionsoff 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:…andq(search) have non-contiguousseqs. Don't use their lastseqas a sync position. Always sync withoutview(whole room). - A deleted message still has a row with its
seq(a tombstone). Deleting never leaves a hole inseq. - 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.limitsmay hold keys not in this table. Ignore keys you don't know.hello.limitsis the value at the moment you connected. If limits change, an open connection isn't told again. The next connect'shellohas them.- Limits are counted in fixed windows (not sliding). At a window edge you can briefly get twice as much.
- Wait
retryAfterMsfrom arate_limitederror 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
- Have a function that gets a user token from your backend. Call it on every connect, and whenever REST returns 401.
- Connect to
wss://api.chat.gravex.app/v1/ws?v=1and sendauthwhen it opens. Afterhelloyou areopen. Keep a state:connecting,open,reconnecting, (closed). - Make frame
ids, and keep a table of requests waiting for a reply byid. Anackresolves it, anerrorfails it. A timeout (15 seconds) and a dropped connection fail the whole table. - Send
pingeveryheartbeatMs. When the app comes back to the foreground or the network returns, send apingat once and reconnect if it fails. - On a drop, reconnect with exponential backoff (500ms to 30s) and jitter. Prefer
reconnect.delayMsandrate_limited.retryAfterMswhen present. Stop and tell the user on an authentication-stepunauthorized,forbiddenorinvalid. - If
hello.user.idchanges, throw away all room state.
Rooms
- Keep per room the
messages(a list in seq order) andcontiguousSeq. - Each time you get
hello, sendsubscribe(since = contiguousSeq) again for every room you are watching. - On
cursor_too_old, drop the list, subscribe again withoutsince, and load recent history. - Fill the gap up to the ack's
lastSequsing RESTGET …/messages?after=. Fill a gap that a livemessage.createdreveals the same way. - Fetch older history for scrolling up with
before=. - 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. - Merge
presenceas increments (joined/left) and replacements (users), and drop the list when onlycountarrives.
Sending
- Make a new
clientMessageIdfor everypublish. If it drops before the ack, send it again with the same value once. - Resend
reactandreadif they drop before the ack. Don't resendtyping. - Send
typingat 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 ownuserId. - Send
readonly forward, with the lastseqthat was really visible on screen. Don't send it too often.
Unread and notifications
- Right after connecting, use
hello.unreadfor the badge if it is there, and if it is absent askGET /v1/me/unread. - For rooms you aren't watching, raise the room-list badge from
notification.message'sunread, and if you may have missed some, readGET /v1/me/roomsagain.
Finishing
- Branch on error
code(wait onrate_limited, showbannedandmoderation_deniedto the user, don't retryquota_exceededorforbidden). - Escape message text and names when you draw them. The engine does not.
- (Optional) When the user presses a reaction, change my
myReactionsfirst 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"}