Protocol reference
The realtime protocol is a JSON message stream over a single WebSocket. The @firemoot/client SDK implements all of this; the reference here is for building another client or understanding the wire.
Connecting
GET /v1/ws?token=<end-user JWT>On a successful upgrade the server sends a hello frame:
{ "type": "hello", "connectionId": "…", "serverTime": "…", "me": { … }, "totalUnread": 0 }totalUnread is the user's global unread badge across every channel. It saturates at 1000: a value of 1000 means "1000 or more", so render it as 1000+ (or clamp further, e.g. 99+).
Heartbeat
The server pings on its own schedule and reaps a connection after a couple of missed pongs. A client may also keep the link warm:
// client -> server
{ "type": "ping" }
// server -> client
{ "type": "pong" }Subscribing and resuming
Subscribe to one or more channels, each with the last seq you have already seen (0 for a fresh subscription):
{ "type": "subscribe", "channels": { "messaging:general": 0 } }The server replays every persisted event after that seq in order, then switches to live delivery. The handoff is atomic: events that arrive during the replay are buffered and spliced in by seq, so there is no gap and no duplicate.
If your resume seq is older than the retained event window, the server replies:
{ "type": "resync_required", "cid": "messaging:general" }Re-query the channel state and re-subscribe from the latest seq (the SDK does this for you).
Typing
Typing indicators are ephemeral - never persisted, never replayed:
{ "type": "typing.start", "cid": "messaging:general" }
{ "type": "typing.stop", "cid": "messaging:general" }Events
Every server event is a frame of the shape:
{ "type": "<event>", "cid": "<channel>", "seq": <number>, "data": { … } }seq is the per-channel sequence (gapless, monotonic) for persisted events, and 0 for ephemeral ones (typing, presence). The event types:
| Event | data carries | Notes |
|---|---|---|
message.new | the message | A new message in a watched channel. |
message.updated | the message | Edit, or a late thumbnail patched onto an attachment. |
message.deleted | the message | Soft delete; text is scrubbed. |
reaction.new | messageId, userId, type, counts | Per-type counts after the change. |
reaction.deleted | messageId, userId, type, counts | |
read.updated | cid, userId, lastReadSeq, unreadCount, totalUnread | Receipt for a member; your own moves your badge. |
typing.start / typing.stop | userId | Ephemeral (seq 0). |
presence.changed | userId, status, lastActiveAt | Online/offline among co-members. |
channel.updated | the channel | Custom data, frozen or archived changed. |
channel.deleted | cid | Soft delete. |
member.added / member.removed | cid, userId, role? | Membership change. |
notification.added_to_channel / notification.removed_from_channel | cid | User-targeted; delivered to the affected user. |
This table mirrors @firemoot/client's typed events.ts - the executable spec the protocol test suite is written against. New event types are added there with their exact data shape.
The persisted events in this table are also what the webhook fan-out delivers, as Stream's payloads under Stream's names (read.updated arrives as message.read); the ephemeral ones (typing, presence) are not. To exercise the resume path yourself, see testing - TcpProxy severs a live connection mid-stream so you can assert nothing is lost or duplicated.