Appearance
Real-time (WebSocket)
Use one WebSocket per signed-in device while the app is in the foreground. It carries message sends, incoming messages, receipts, typing, presence, and friend events.
Connect
wss://api.chatter.maragato.ca/v1/ws?clientKey=<clientKey>&token=<accessToken>On success the server sends:
json
{
"type": "session.ready",
"data": {
"userId": "01JB…",
"deviceId": "01JB8ZD…",
"serverTime": "2026-10-22T14:03:11.482Z",
"heartbeatIntervalMs": 25000
}
}Immediately after session.ready, the server sends a presence.snapshot of all friends.
If the access token is expired or invalid, the server closes the socket with code 4001. Refresh the token and reconnect.
Frame format
Every frame, in both directions, is a JSON text frame:
json
{ "id": "c-42", "type": "message.send", "data": { } }| Field | Notes |
|---|---|
id | Optional. Set it on client frames that expect a reply. The reply carries the same value in replyTo. |
type | Event type. Ignore unknown types. |
data | Event payload. |
An error in reply to a client frame:
json
{ "type": "error", "replyTo": "c-42", "data": { "code": "NOT_FRIENDS", "message": "You can only message friends." } }Error codes are the same as in REST.
Client-to-server events
type | data | Reply |
|---|---|---|
ping | {} | pong |
message.send | See below | message.ack or error |
receipt.delivered | { "conversationId", "seq" } | none |
receipt.read | { "conversationId", "seq" } | none |
typing.start | { "conversationId" } | none |
typing.stop | { "conversationId" } | none |
sync.request | { "conversations": { "<conversationId>": <lastSeq> }, "limitPerConversation": 100 } | sync.response |
Heartbeat
Send ping every 25 seconds. The server closes connections without traffic for 60 seconds. If you get no pong within 10 seconds, treat the connection as dead and reconnect.
message.send
json
{
"id": "c-42",
"type": "message.send",
"data": {
"conversationId": "01JE…",
"clientMessageId": "6f1c2c1e-6b5f-4d0e-9a3b-0a8f2d1b7c55",
"type": "text",
"body": "See you at 8?",
"mediaId": null,
"replyToId": null
}
}Reply:
json
{
"type": "message.ack",
"replyTo": "c-42",
"data": {
"clientMessageId": "6f1c2c1e-6b5f-4d0e-9a3b-0a8f2d1b7c55",
"messageId": "01JF…",
"conversationId": "01JE…",
"seq": 42,
"createdAt": "2026-10-22T14:20:11.482Z"
}
}Sending the same clientMessageId again returns the same ack. No duplicate is created. So on reconnect, simply resend every unacknowledged message.
The sender's other devices receive the message as message.new. The sending device receives only the ack.
Typing
Send typing.start when the user types, and again every 3 seconds while typing continues. Send typing.stop when the text field is cleared or the message is sent. The server ends typing automatically after 5 seconds without typing.start.
Sync
After every (re)connect, send sync.request with the highest contiguous seq you have for each conversation:
json
{ "id": "c-1", "type": "sync.request", "data": { "conversations": { "01JE…": 40, "01JG…": 7 } } }Reply:
json
{
"type": "sync.response",
"replyTo": "c-1",
"data": {
"conversations": {
"01JE…": {
"messages": [ { "…": "Message seq 41" }, { "…": "Message seq 42" } ],
"hasMore": false,
"members": [ { "userId": "01JC…", "lastDeliveredSeq": 42, "lastReadSeq": 40 } ]
}
}
}
}If hasMore is true, continue with GET /conversations/{id}/messages?afterSeq=. Conversations you do not know yet are not included — discover them with GET /conversations?updatedSince=.
Server-to-client events
type | data | Notes |
|---|---|---|
session.ready | { userId, deviceId, serverTime, heartbeatIntervalMs } | First frame after connect. |
pong | {} | Reply to ping. |
message.ack | { clientMessageId, messageId, conversationId, seq, createdAt } | Reply to message.send. |
message.new | Message | New message in any of your conversations. |
message.updated | Message | Edit, delete for everyone, or reaction change. |
receipt.updated | { conversationId, userId, lastDeliveredSeq, lastReadSeq } | A member's watermark moved (including your own other devices). |
typing | { conversationId, userId, isTyping } | |
presence | { userId, isOnline, lastSeenAt } | A friend came online or went offline. |
presence.snapshot | { users: [ { userId, isOnline, lastSeenAt } ] } | All friends, after session.ready. |
conversation.created | Conversation | Someone started a conversation with you. |
conversation.updated | Conversation | Group title, avatar, members, or roles changed. |
conversation.removed | { conversationId } | You were removed from a group. |
friend_request.received | FriendRequest | |
friend_request.accepted | { requestId, friend: Friend } | Sent to both users. |
friend_request.cancelled | { requestId } | Sent to the recipient. |
friend.removed | { userId } | |
user.updated | User | A friend changed their profile. |
sync.response | See Sync | |
error | { code, message, details? } |
Presence
- Presence is shared only between friends, and only if the user's
privacy.lastSeenallows it. - A user is online while at least one of their devices has an open socket.
- When the last socket closes, the server waits 10 seconds (to absorb short reconnects), then sends
presencewithisOnline: falseandlastSeenAt.
Delivery guarantees
| Event | Guarantee |
|---|---|
message.new, message.updated | At least once while connected. Deduplicate by id. After reconnect, sync.request fills any gap. |
message.ack | Exactly one per message.send frame received by the server. If the socket drops before the ack, resend — the ack will come for the same message. |
receipt.updated | Best effort. The latest values are also in GET /conversations/{id} and sync.response. |
typing, presence | Best effort. Can be dropped. |
| Friend and conversation events | At least once while connected. After reconnect, refresh with REST. |
Close codes
| Code | Meaning | What the app does |
|---|---|---|
1000 | Normal close | — |
1001 | Server restarting | Reconnect with backoff. |
1006 | Network lost (client-side) | Reconnect with backoff when the network is back. |
4001 | Token invalid or expired | Refresh token, then reconnect. |
4002 | Client key invalid | Do not reconnect. Configuration error. |
4003 | Session revoked (logout, account deleted) | Clear local data, go to the welcome screen. |
4008 | Rate limited | Reconnect after the delay in the close reason (seconds). |
Reconnect backoff: 1 s, 2 s, 4 s, 8 s, 16 s, 30 s (max), each with ±20 % random jitter. Reset after a successful session.ready. Reconnect immediately when iOS reports that the network path changed to satisfied.
Example session
App Server
│ connect ?clientKey=…&token=… │
│────────────────────────────────────────▶│
│ session.ready │
│◀────────────────────────────────────────│
│ presence.snapshot │
│◀────────────────────────────────────────│
│ sync.request {01JE…: 40} (c-1) │
│────────────────────────────────────────▶│
│ sync.response (seq 41, 42) │
│◀────────────────────────────────────────│
│ receipt.read {01JE…, 42} │
│────────────────────────────────────────▶│
│ message.send "On my way" (c-2) │
│────────────────────────────────────────▶│
│ message.ack seq 43 │
│◀────────────────────────────────────────│
│ receipt.updated Ben delivered 43 │
│◀────────────────────────────────────────│
│ typing Ben true │
│◀────────────────────────────────────────│
│ message.new seq 44 "Great!" │
│◀────────────────────────────────────────│