Skip to content

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": { } }
FieldNotes
idOptional. Set it on client frames that expect a reply. The reply carries the same value in replyTo.
typeEvent type. Ignore unknown types.
dataEvent 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 ​

typedataReply
ping{}pong
message.sendSee belowmessage.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 ​

typedataNotes
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.newMessageNew message in any of your conversations.
message.updatedMessageEdit, 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.createdConversationSomeone started a conversation with you.
conversation.updatedConversationGroup title, avatar, members, or roles changed.
conversation.removed{ conversationId }You were removed from a group.
friend_request.receivedFriendRequest
friend_request.accepted{ requestId, friend: Friend }Sent to both users.
friend_request.cancelled{ requestId }Sent to the recipient.
friend.removed{ userId }
user.updatedUserA friend changed their profile.
sync.responseSee Sync
error{ code, message, details? }

Presence ​

  • Presence is shared only between friends, and only if the user's privacy.lastSeen allows 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 presence with isOnline: false and lastSeenAt.

Delivery guarantees ​

EventGuarantee
message.new, message.updatedAt least once while connected. Deduplicate by id. After reconnect, sync.request fills any gap.
message.ackExactly 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.updatedBest effort. The latest values are also in GET /conversations/{id} and sync.response.
typing, presenceBest effort. Can be dropped.
Friend and conversation eventsAt least once while connected. After reconnect, refresh with REST.

Close codes ​

CodeMeaningWhat the app does
1000Normal close—
1001Server restartingReconnect with backoff.
1006Network lost (client-side)Reconnect with backoff when the network is back.
4001Token invalid or expiredRefresh token, then reconnect.
4002Client key invalidDo not reconnect. Configuration error.
4003Session revoked (logout, account deleted)Clear local data, go to the welcome screen.
4008Rate limitedReconnect 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!"             │
 │◀────────────────────────────────────────│

Questions? Reply to your invitation email.