Mobile Chat — Integration Guide
For mobile development. Everything needed to authenticate, list conversations, send / receive messages in real time, edit / delete, mark read, handle typing indicators, manage groups, recover from network drops, and receive push notifications. No backend changes needed on your side.
- API base URL (local):
http://localhost:3001 - WebSocket URL: same host, Socket.IO v4 client
- All HTTP requests are JSON. Send
Content-Type: application/json. - Send
X-Client-Type: mobileon every request. - Auth:
Authorization: Bearer <accessToken>on every endpoint below. - WebSocket auth: pass JWT in the handshake
auth.tokenfield (see §4).
Short version — just the calls
0. POST /api/devices → register push token (once per install)
1. GET /api/chat/conversations?folder=inbox → main inbox (cursor-paginated) with unreadCount
GET /api/chat/conversations?folder=requests → message-requests tray (incoming PENDING DMs)
2. POST /api/chat/conversations → create DM or group (idempotent for DM; DMs to
non-followers land in recipient's requests tray)
3. POST /api/chat/conversations/:id/accept → recipient accepts a PENDING request → ACCEPTED
POST /api/chat/conversations/:id/decline → recipient declines → hard-deletes conversation
4. PATCH /api/chat/conversations/:id → rename / lock messaging (owner/admin)
5. GET /api/chat/conversations/:id/messages?limit=30
→ paginated history (newest-first)
GET /api/chat/conversations/:id/messages?afterSequence=N
→ reconnect gap-fill (oldest-first)
6. POST /api/chat/conversations/:id/messages → send (idempotent with clientMessageId)
7. POST /api/chat/conversations/:id/read → mark all read
8. PATCH /api/chat/messages/:messageId → edit (15-min window)
DELETE /api/chat/messages/:messageId → soft delete
GET /api/chat/messages/:messageId/info → per-recipient receipts (sender only)
9. POST /api/chat/conversations/:cid/participants
→ add members (group, owner/admin)
DELETE /api/chat/conversations/:cid/participants/:userId
→ remove member (soft, owner/admin)
PATCH /api/chat/conversations/:cid/participants/:userId
→ promote/demote (owner/admin)
10. POST /api/blocks/:userId → block user (cascades: unfollow + soft-leave shared DMs)
DELETE /api/blocks/:userId → unblock (no auto-resume)
GET /api/blocks → blocked accounts list (paginated)
11. WebSocket events — see §4 + §5
GET /conversations, single message endpoints, and message lists are NOT
cached at the CDN; they're authenticated per-user. Rely on the WebSocket for
realtime, the HTTP endpoints for sync.
1 — Push device registration (do this on first launch + after login)
POST /api/devices
{
"token": "<FCM or APNs token>",
"platform": "ANDROID", // IOS | ANDROID | WEB
"appVersion": "1.0.3", // optional
"locale": "en-US", // optional
"timezone": "Asia/Tokyo" // optional
}
Response: { success: true, data: { id, userId, token, platform, ... } }.
Idempotent on the token — calling again with the same token updates lastSeenAt
and any metadata you pass. Store the returned id so you can DELETE /api/devices/:id on logout.
Note: FCM/APNs delivery is implemented end-to-end on the server side except for the production FCM adapter (currently a console stub). Once the adapter is wired (see
CHAT_IMPLEMENTATION_V2.md§6), all the notifications you already register for will actually fan out to your device.
1.1 — On logout
DELETE /api/devices/:id
Stops sending pushes to that token. The user's other devices keep receiving.
1.2 — Optional: list devices
GET /api/devices
Returns this user's registered devices — handy for "where am I logged in?" screens.
2 — Listing conversations
GET /api/chat/conversations?folder=inbox&limit=30
GET /api/chat/conversations?folder=requests&limit=30
GET /api/chat/conversations?folder=inbox&cursor=<opaque>
Cursor-paginated. Ordered by updatedAt DESC. Returns conversations the
user is an active participant of (excludes groups the user was removed
from). Default limit 30, max 100. Default folder is inbox.
2.1 — Folder semantics
inbox— main tray. Contains:- all ACCEPTED conversations the user participates in, AND
- PENDING DMs where the viewer is the sender (per Instagram: your sent message requests stay in your main inbox).
requests— incoming message-requests tray. Contains PENDING DMs where the viewer is the recipient (someone the user does not yet follow has DM'd them). Render these as a separate tab with accept / decline actions (see §6).
{
"success": true,
"folder": "inbox", // ← echoes the requested tray
"data": [
{
"id": "cnv_xxx",
"name": null, // null for DMs, optional for groups
"isGroup": false,
"status": "ACCEPTED", // ← NEW: "PENDING" | "ACCEPTED"
"requesterId": null, // ← NEW: initiator of a PENDING DM (preserved after accept)
"messagingRestricted": false,
"createdAt": "...",
"updatedAt": "...", // bumped on every new message
"participants": [
{
"id": "cpt_yyy",
"userId": "usr_a",
"role": "OWNER",
"joinedAt": "...",
"leftAt": null,
"user": {
"id": "usr_a",
"username": "alice",
"profile": { "displayName": "Alice", "avatarUrl": "..." },
},
},
],
"messages": [
{
"id": "msg_zzz",
"content": "...",
"senderId": "usr_a",
"sequenceNumber": 42,
"createdAt": "...",
},
],
"unreadCount": 3,
},
],
"nextCursor": "MjAyNi0wNS0yNlQxMDow...", // null on final page
}
Pass nextCursor back as ?cursor= for the next page. Stop when null.
Cursor is opaque — base64-encoded updatedAt|id server-side; do not parse.
Render:
- DMs (
isGroup=false) → show the other participant's display name + avatar. - Groups (
isGroup=true) → shownameor fall back to a comma-joined member list. - Unread badge →
unreadCount. The socket pushes deltas viachat_list_update; trust the HTTP value on every refetch. - Requests badge → drive from the
requestsbucket inunread_counts_init(see §4.4) orGET /api/chat/conversations?folder=requests. Count of distinct PENDING conversations is the canonical "X new requests" number, not the message count inside them.
3 — Creating a conversation (DM or group)
POST /api/chat/conversations
{
"participantIds": ["usr_b"], // other users — current user added server-side
"isGroup": false, // omit / false for DM
"name": "Study group" // optional, group only
}
- DM dedup is automatic. If a DM between the same two active users already exists, the server returns that one — no duplicate.
- DM gate (NEW): strangers can DM each other. Behavior depends on the
follow graph at create time:
- If the recipient already follows the sender → conversation created
with
status: "ACCEPTED", lands in recipient's main inbox. - Otherwise → conversation created with
status: "PENDING",requesterId = sender. Lands in recipient's Requests tray (see §6). Sender still sees the DM in their own main inbox.
- If the recipient already follows the sender → conversation created
with
- Auto-accept on dedup: if a PENDING DM already exists and the
recipient (not the requester) calls
POST /conversationsfor that pair after the recipient has come to follow the requester, the server flips the row to ACCEPTED in-line and firesconversation_status_changedto both sides. Use this if the user opens a DM via search rather than the requests tray. - Group gate (unchanged): the requester must have an accepted follow
relationship (in either direction) with every invitee. Otherwise →
403 with reason
NOT_CONNECTED. - 403 + BLOCKED: at least one direction of
Blockexists between the sender and a target. Surface as "You cannot start a conversation with this user." - 403 + UNKNOWN_USER: one or more participantIds don't exist.
- 400: more than 50 participants (current cap).
Response is the full conversation object (same shape as the list, with
status + requesterId).
Navigate to /chat/:id after creation — the conversation room socket
subscription happens on the chat screen mount (see §4). For a PENDING
DM you can still open the room as the sender; the recipient will see it
in their requests tray (not their inbox) until they accept.
3.1 — Update group settings (owner/admin only)
PATCH /api/chat/conversations/:id
{
"name": "New name", // optional
"messagingRestricted": true // optional — when true, only admins/owner can send
}
When messagingRestricted: true, members trying to send get a 403.
4 — WebSocket connection (real-time)
The system is a hybrid push/pull model:
- PUSH (WebSocket) — new messages, read receipts, typing, edits, deletes.
- PULL (HTTP) — history, conversation list, reconnect gap-fill.
4.1 — Connect
import { io } from "socket.io-client";
const socket = io("http://localhost:3001", {
auth: { token: accessToken }, // JWT — verified server-side
transports: ["websocket"], // skip long-polling on mobile
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
socket.on("connect", () => {
// You're in your personal room (user:<id>) automatically.
// The server emits unread_counts_init right after — see §4.4.
});
socket.on("connect_error", (err) => {
// Token rejected or invalid. Re-auth and retry, do NOT spam reconnect.
if (
err.message === "Invalid token" ||
err.message === "Authentication required"
) {
refreshAccessToken().then(() => (socket.auth = { token: newToken }));
}
});
socket.on("disconnect", (reason) => {
// reason === "io server disconnect" → don't auto-reconnect, server kicked us
// Check if it's an auth expiry — see §4.5 below
// reason === "transport close" / "ping timeout" → socket.io will reconnect
// On reconnect, run the gap-fill flow (see §6)
});
4.5 — Token lifecycle (refresh while connected)
The JWT used for the socket handshake expires (15 min by default). The server tracks each socket's token expiry and emits two events:
// Fired ~60s BEFORE the token expires. Refresh the access token now.
socket.on("auth_expiring", ({ expiresAt }) => {
refreshAccessToken().then((newToken) => {
socket.emit("refresh_auth", newToken);
// On success, server resets the lifecycle timer silently.
// On failure, server emits "auth_expired" and disconnects.
});
});
// Fired AT expiry (refresh failed, never sent, or new token rejected).
// Server disconnects immediately after this — no further events arrive.
socket.on("auth_expired", ({ reason }) => {
// reason: "identity_mismatch" | "invalid_token" | "no_exp_claim" | undefined
// Trigger your standard re-auth flow (login screen / refresh + reconnect).
});
Refresh contract:
- Send the new access token (not the refresh token) on
socket.emit("refresh_auth", token). - The user identity inside the new token MUST match the socket's original user — switching users requires a full reconnect.
- If you don't refresh in time, the server disconnects you. Reconnect with
the fresh token in
auth.tokenand run gap-fill (see §6). - Token must contain
exp(any token signed via the API's auth endpoints does).
Don't pre-emptively reconnect on every token refresh — just emit
refresh_auth. Reconnect is a much heavier operation (re-deliverunread_counts_init, re-join rooms, etc.).
4.2 — Join the active conversation room
When the user opens a chat screen:
socket.emit("join_conversation", conversationId);
// Server auto-marks all unread messages in this conversation as read
// and broadcasts a `messages_read` event to other participants.
When the user closes the chat screen:
socket.emit("leave_conversation", conversationId);
You only need to be in one conversation room at a time. The personal room
(user:<id>) handles all the cross-conversation sidebar updates.
4.3 — Typing indicator
// On keystroke (debounce 200–500ms so you don't fire on every char)
socket.emit("typing_start", conversationId);
// On send OR 3s of inactivity OR screen blur
socket.emit("typing_stop", conversationId);
// Listen for others
socket.on("user_typing", ({ userId, conversationId }) => {
// Show indicator. Auto-hide on `user_stopped_typing` or after a 5s timeout
// (defensive — covers a dropped stop event).
});
socket.on("user_stopped_typing", ({ userId, conversationId }) => {
/* hide */
});
4.4 — On connect: unread counts seed
The server emits unread_counts_init automatically right after connect.
Payload now splits the badge into the two trays:
socket.on("unread_counts_init", ({ counts, inbox, requests }) => {
// counts: Record<conversationId, number> — back-compat: same as inbox
// inbox: Record<conversationId, number> — main-tray unread
// requests: Record<conversationId, number> — requests-tray unread
// Use `inbox` for the main sidebar badge. Use `requests` (count of keys =
// distinct pending conversations with >0 unread) for the Requests tab dot.
});
- Legacy clients reading
countskeep working — it carries the inbox bucket only. - It's safe to also call
GET /api/chat/conversations?folder=...— per-rowunreadCountis the authoritative value for that tray.
4.6 — Tray transitions (PENDING ↔ ACCEPTED, declined)
// Recipient accepted a PENDING request, OR sender's pending DM got
// auto-accepted by the dedup-auto-accept path (see §3). Fires to both
// participants.
socket.on("conversation_status_changed", ({ conversationId, status }) => {
// status: "ACCEPTED"
// Move the row from `requests` → `inbox` locally. No need to refetch.
});
// Recipient declined a PENDING request. Server hard-deleted the row.
// Fires to both participants. Drop the conversation from local caches.
socket.on("conversation_declined", ({ conversationId }) => {
// Remove from inbox (sender side) AND requests (recipient side).
// Drop any open chat-room subscription for this id.
});
// `chat_list_update` now carries `status` so the client routes to the
// right tray. For a PENDING DM, the recipient's `unreadIncrement` is
// always 0 — the requests-tab dot tracks distinct conversations, not
// messages inside them.
socket.on(
"chat_list_update",
({ conversationId, status, lastMessage, unreadIncrement }) => {
// status: "PENDING" | "ACCEPTED" — pick the tray to bump
},
);
5 — Sending and receiving messages
5.1 — Send
POST /api/chat/conversations/:id/messages
{
"content": "hello world",
"clientMessageId": "f8a1c2e0-…" // ⚠️ recommended — see §5.2
}
Response:
{
"success": true,
"data": {
"id": "msg_xxx",
"content": "hello world",
"senderId": "usr_a",
"conversationId": "cnv_yyy",
"sequenceNumber": 43,
"clientMessageId": "f8a1c2e0-…",
"type": "TEXT",
"isDeleted": false,
"editedAt": null,
"createdAt": "2026-05-26T10:30:00Z",
"sender": { "id": "usr_a", "username": "alice", "profile": { ... } },
"status": {
"type": "sent",
"totalRecipients": 2,
"deliveredCount": 0,
"readCount": 0,
"allDelivered": false,
"allRead": false
},
"deduplicated": false // present + true on idempotent replay
}
}
Error codes:
| code | meaning |
|---|---|
| 403 | Not a participant / removed / messagingRestricted member |
| 404 | Conversation not found |
| 429 | Rate limit (60 messages/min per user) |
5.2 — Idempotency (do this — mobile networks are flaky)
Generate a UUID (clientMessageId) on the client before the first send
attempt. Send the same UUID on every retry of the same intended message.
- First successful response → save the
idserver returned, mark the message as sent locally. - Network timeout / 5xx → safe to retry with the same
clientMessageId. - The server will return the existing row with
deduplicated: trueinstead of creating a duplicate. - Don't reuse a
clientMessageIdfor a different message — once persisted, it's bound to that content.
// Pseudo-code
const clientMessageId = crypto.randomUUID();
async function sendWithRetry(content: string, attempt = 0): Promise<Message> {
try {
const res = await api.post(`/chat/conversations/${id}/messages`, {
content,
clientMessageId,
});
return res.data.data;
} catch (err) {
if (attempt < 3 && isTransient(err)) {
await sleep(2 ** attempt * 1000);
return sendWithRetry(content, attempt + 1);
}
throw err;
}
}
5.3 — Optimistic UI (mandatory pattern)
- On tap-send — append a local message with
id: "temp-<timestamp>",status: "pending", yourclientMessageId. Show clock icon. - Fire POST in the background. Do not block the input on the response.
- On
new_messagesocket event — if it's your own message (sender matches you), reconcile byclientMessageId→ replace the temp row. - On HTTP success — if the socket beat you to it (race), the temp row is already gone. Otherwise, replace temp with the returned row.
- On HTTP error after retries — flip status to
failed, surface "Tap to retry". User can retry with the sameclientMessageId.
5.4 — Receive
socket.on("new_message", (message) => {
// Only fires for sockets in the conversation room (open chat screens).
// Append to cache. Auto-mark read if we're viewing this conversation.
if (currentConversationId === message.conversationId) {
socket.emit("mark_read", { conversationId: message.conversationId });
}
});
socket.on(
"chat_list_update",
({ conversationId, lastMessage, unreadIncrement }) => {
// Fires for the personal room (all online users in the conversation).
// Use this to bump sidebar badges without re-fetching the whole list.
},
);
5.5 — Delivery & read receipts (sender view)
socket.on("message_status_update", ({ messageId, conversationId, status }) => {
// status: "sent" — server has persisted the row
// (status flips to delivered/read are emitted via message_delivered / messages_read)
});
socket.on(
"message_delivered",
({ messageId, conversationId, userId, deliveredAt }) => {
// One row per recipient. For 1:1 chats, a single event means "delivered".
// For groups, aggregate across recipients to show "1 of 3 received".
},
);
socket.on(
"messages_read",
({
conversationId,
readByUserId,
readAt,
isGroupChat,
messageReadStatuses,
}) => {
// For DMs: flip all messages older than readAt to "read" for this conversation.
// For groups: messageReadStatuses[] gives per-message read counts.
},
);
To inspect aggregate status for a sent message:
GET /api/chat/messages/:messageId/info
Returns the message + every recipient's receipt + the active participant list. Sender-only — 403 otherwise.
5.6 — Mark read (manual)
If the user lingers without scrolling but a new message arrives, the
auto-mark from join_conversation is enough. To force-mark from elsewhere:
POST /api/chat/conversations/:id/read
Or via socket (no HTTP roundtrip):
socket.emit("mark_read", { conversationId });
Both broadcast messages_read to the conversation room.
5.7 — Edit (15-minute window)
PATCH /api/chat/messages/:messageId
{ "content": "fixed typo" }
- 403 if not the sender, or if older than 15 minutes.
- 404 if not found.
- Emits
message_editedto the conversation room.
socket.on(
"message_edited",
({ messageId, conversationId, content, editedAt }) => {
// Update local cache. Show "edited" label.
},
);
5.8 — Delete (soft)
DELETE /api/chat/messages/:messageId
Sets isDeleted = true, content = "This message was deleted". Mentions
tied to the message are dropped. Emits message_deleted.
socket.on("message_deleted", ({ messageId, conversationId }) => {
// Replace the row's content with "This message was deleted" + dim styling.
});
5.9 — Sending into a PENDING DM (request thread)
Send semantics are unchanged. The sender can keep writing into a PENDING thread freely — there is no per-thread message cap. Two things to know on the UI side:
- For the sender, the per-message
statusaggregate stays atdeliveredCount=0, readCount=0until the recipient accepts. The recipient is intentionally not generating delivery receipts during PENDING — we don't leak "the stranger you DM'd has seen your message" before they decide. - When the recipient calls
/accept, the server backfills receipts for every prior message the requester sent and setsdeliveredAt = acceptTimeon each. The sender'sstatusaggregate jumps toallDelivered=trueafter the nextmessage_deliveredsocket event orGET /messages/:id/infocall.
The recipient can read PENDING messages via the usual
GET /api/chat/conversations/:id/messages without flipping the
conversation to ACCEPTED — peeking at a request is not the same as
accepting it.
5.10 — Sharing a post (native card)
In-app post sharing uses a native message type — not a pasted URL. The
message stores only the postId; the server renders a live, per-viewer card
on read. This is the in-app path; the URL flow
(POST /api/posts/:id/share, see mobile-posts-guide.md) stays for
external channels (WhatsApp, Messages, email).
Why native: the card renders instantly (no OG-preview generation), works with link previews disabled, and its counts/thumbnail are always current because the body is resolved fresh on every read — never a frozen snapshot.
Send
POST /api/chat/conversations/:id/messages
{
"postId": "pst_abc", // the post being shared
"content": "you should join!", // optional caption (≤2000)
"clientMessageId": "…" // same idempotency rules as any message
}
- The message comes back with
type: "POST_SHARE"andsharedPostId, but no card body — resolve it (see below). postIdandmediaare mutually exclusive → 400 if both sent.404if the post is missing/hidden or you can't view it (can't share what you can't see).- A
PostSharerow (channel: CHAT) is recorded, so the post's share count increments like a URL share.
Resolving the card (one path for history + real-time)
The card is never carried on the new_message socket broadcast (a single
broadcast can't hold a viewer-specific card). Two ways to get it, both the same
resolver + shape:
-
History —
GET /api/chat/conversations/:id/messagesalready inlinessharedPoston eachPOST_SHARErow. -
Real-time — on a
new_messagewithtype: "POST_SHARE", batch-resolve via:POST /api/posts/share-cards{ "postIds": ["pst_abc", "pst_def"] } // dedupe across the visible pageReturns
data.cardsin request order. Cache per postId; the counts are live so refetch when you want fresh numbers (e.g. reopening the thread).
sharedPost shape + the three render states
// available — render the full card
{
"id": "pst_abc",
"available": true,
"author": { "id": "…", "username": "alice", "grade": "8", "school": "Lincoln HS",
"profile": { "displayName": "Alice", "avatarUrl": "…" } },
"type": "FIND_TEAMMATES",
"title": "Chess team",
"content": "Looking for a chess team…", // whole body — clamp for display
"thumbnailUrl": "https://…/thumb.jpg", // or null
"mediaCount": 2,
"counts": { "likes": 12, "comments": 3, "reposts": 1, "shares": 4 },
"createdAt": "2026-07-09T…Z"
}
// private — author is private and the viewer isn't an accepted follower.
// Render an identity-only locked card ("Follow to view").
{ "id": "pst_abc", "available": false, "reason": "private",
"author": { "id": "…", "username": "alice", "profile": { … } } }
// unavailable — post deleted/hidden, or the author blocked the viewer.
// NO identity. Render "Post unavailable".
{ "id": "pst_abc", "available": false, "reason": "unavailable" }
Tapping an available card deep-links to the post; a private/unavailable
card is non-navigable.
6 — Message requests (accept / decline / hide flow)
The requests tray is a separate tab. Render the row count from
unread_counts_init.requests (count of keys) or from the length of the
folder=requests page. Each row exposes two actions.
6.1 — Accept
POST /api/chat/conversations/:id/accept
- 200 with the updated conversation (
status="ACCEPTED"). - Idempotent — already-ACCEPTED returns 200 no-op.
- 403 if the caller is the requester (only the recipient can accept).
- 403 if a Block was created between request and accept.
Side effects you'll observe on the client:
conversation_status_changedsocket event fires to both participants.- The requester receives a
message_request_acceptednotification withdata.conversationId. Deep-link straight into the chat. - All
MessageReceiptrows the requester was missing are backfilled withdeliveredAt = now. The nextmessage_deliveredsocket event for the sender reflects this.
6.2 — Decline
POST /api/chat/conversations/:id/decline
- 204 on success — conversation + messages + receipts are hard deleted. There's no DECLINED state to render. Drop the row from every local cache.
- 404 if the conversation isn't PENDING (already accepted or never existed).
- 403 if the caller is the requester.
Side effects:
conversation_declinedsocket event fires to both participants. Sender's UI must drop the row from their inbox (it's gone server-side too — any retry POST will 404).- No notification fires to the requester. Instagram model: declines are silent.
6.3 — UI suggestions
- Stack the request rows with the actor's avatar + the first ~60 characters of the latest message as a preview.
- Surface
Accept+Delete(decline) as a two-button row. Optionally surfaceBlock(POST/api/blocks/:userId) as a third menu item — blocking auto-soft-leaves the DM (see §13) so the user does not need to also decline. - After accept, navigate into the conversation. After decline, stay on the requests tab and remove the row.
7 — Loading history & reconnect gap-fill
6.1 — Initial history (newest-first)
GET /api/chat/conversations/:id/messages?limit=30
Response:
{
"success": true,
"data": {
"items": [
/* messages, oldest→newest within page */
],
"nextCursor": "42", // a sequenceNumber, or null on first page exhaustion
"mode": "history",
},
}
limit defaults to 50, capped at 200. Calling this endpoint also marks
undelivered receipts for you as delivered (covers the offline-but-app-open
case where the socket isn't bound yet).
6.2 — Paginate older
GET /api/chat/conversations/:id/messages?cursor=42&limit=30
Returns messages with sequenceNumber < 42. Stop when nextCursor is null.
6.3 — Reconnect gap-fill (the important one)
When the socket reconnects after a network drop, fetch every message you might have missed:
GET /api/chat/conversations/:id/messages?afterSequence=42
Returns messages with sequenceNumber > 42 ascending (oldest-first).
mode in the response will be "gap". Use nextCursor to keep walking
forward until it's null. Each conversation tracks its own
sequenceNumber — store the highest seen per conversation locally.
// On socket reconnect:
async function backfillConversation(conversationId: string) {
let cursor: string | null = String(
localState.highestSeqSeen[conversationId] ?? 0,
);
while (cursor !== null) {
const res = await api.get(
`/chat/conversations/${conversationId}/messages?afterSequence=${cursor}&limit=100`,
);
for (const msg of res.data.data.items) {
appendToCache(conversationId, msg);
}
cursor = res.data.data.nextCursor;
}
}
You only need to gap-fill conversations the user actually opens — for the
sidebar, the unread_counts_init event + chat_list_update events keep
badges current.
8 — Groups: managing participants
8.1 — Add members (owner/admin only)
POST /api/chat/conversations/:conversationId/participants
{ "userIds": ["usr_d", "usr_e"] }
Re-adding a previously-removed user clears their leftAt (they regain
access — including history before they were removed). Emits
participants_added to the room and added_to_conversation to each new
user's personal room.
8.2 — Remove member (soft, owner/admin only)
DELETE /api/chat/conversations/:conversationId/participants/:userId
Sets leftAt = now(). Removed user keeps history up to their leftAt
timestamp but can't send. Emits participant_removed and
removed_from_conversation.
⚠️ Owner cannot be removed. 403 returned.
8.3 — Promote / demote (owner/admin only)
PATCH /api/chat/conversations/:conversationId/participants/:userId
{ "role": "ADMIN" } // or "MEMBER"
Owner role can't be reassigned via this endpoint. Emits
participant_updated.
8.4 — Listening for group state changes
socket.on("participants_added", ({ conversationId, participants }) => {
/* … */
});
socket.on("participant_removed", ({ conversationId, userId }) => {
/* … */
});
socket.on("participant_updated", ({ conversationId, participant }) => {
/* … */
});
socket.on("conversation_updated", (updatedConversation) => {
/* … */
});
// Targeted at the affected user's personal room — not the conversation room:
socket.on("added_to_conversation", ({ conversationId }) => {
/* refetch list */
});
socket.on("removed_from_conversation", ({ conversationId }) => {
/* drop from list */
});
9 — Mentions (@username) in chat
Server auto-detects @username in content. In group chats:
- Mentioned users get a
mentionnotification (type: "mention"). - The generic
messagenotification is suppressed for those users — they only see the more specific mention push.
In DMs: mention notifications are skipped (the recipient already gets a
message notification regardless).
Render the mention by detecting @ + matching the resolved username from
the message; the actual Mention rows can be fetched from the post/comment
mention endpoint if needed. Most clients just style @x substrings inline.
10 — Pagination cursors
Chat messages use a numeric sequenceNumber cursor (string-encoded in the
URL). Treat it as opaque — pass back nextCursor exactly as received. The
server returns nextCursor: null on the final page.
Conversation list is cursor-paginated per folder (inbox or
requests). Default 30 / max 100. Cursor is an opaque base64-encoded
updatedAt|id tuple — just pass nextCursor back as ?cursor=.
11 — Error responses
Validation errors → HTTP 400 with details (same shape as posts guide §12).
| code | meaning |
|---|---|
| 401 | Missing or invalid Authorization / socket auth.token |
| 403 | Not a participant / removed / messagingRestricted / not connected / BLOCKED / not the recipient (accept/decline) |
| 404 | Conversation, message, participant not found, or conversation no longer PENDING (decline) |
| 429 | Rate limit (60 messages/min/user; 100 mentions/24h) |
| 500 | Server error — capture request_id if present |
12 — Push notifications (mobile background)
There are now three chat-related notification types you must route:
| type | When it fires | Deep-link |
|---|---|---|
message | New message in an ACCEPTED conversation, OR new message from the requester in their own PENDING DM (sender hits send-confirm push themselves only if they have multiple devices) | ksn://chat/:conversationId?messageId=:id |
message_request | First / subsequent message from a non-follower in a PENDING DM, to the recipient | ksn://chat/requests/:conversationId |
message_request_accepted | Recipient tapped Accept on a request the user sent | ksn://chat/:conversationId |
mention | @username mention in a group chat | ksn://chat/:conversationId?messageId=:id (mentions take priority — the message notif is suppressed for the mentioned user) |
message payload shape:
{
"notification": {
"title": "Alice", // or "Group name: Alice" for groups
"body": "hello world", // truncated to 140 chars
},
"data": {
"entityType": "message",
"entityId": "msg_xxx",
"actorId": "usr_a",
"actorName": "Alice",
"conversationId": "cnv_yyy",
"deepLink": "ksn://chat/cnv_yyy?messageId=msg_xxx",
"webPath": "/chat/cnv_yyy",
},
}
message_request payload shape (route this into the requests tray, not
the main inbox):
{
"notification": {
"title": "Alice sent you a message request",
"body": "hello world",
},
"data": {
"entityType": "conversation",
"entityId": "cnv_yyy",
"actorId": "usr_a",
"actorName": "Alice",
"conversationId": "cnv_yyy",
"deepLink": "ksn://chat/requests/cnv_yyy",
"webPath": "/chat/requests/cnv_yyy",
},
}
message_request_accepted payload shape (sender gets this when their
request lands in the recipient's inbox):
{
"notification": {
"title": "Bob accepted your message request",
"body": null,
},
"data": {
"entityType": "conversation",
"entityId": "cnv_yyy",
"actorId": "usr_b",
"actorName": "Bob",
"conversationId": "cnv_yyy",
"deepLink": "ksn://chat/cnv_yyy",
"webPath": "/chat/cnv_yyy",
},
}
12.1 — Deep-link handling
Parse data.deepLink (or data.conversationId + data.entityId). Open
the chat screen, scroll to / highlight the referenced message (gap-fill
if your local history doesn't reach sequenceNumber yet).
For message_request deep-links (/chat/requests/:id), open the
Requests tab and scroll the row into view rather than entering the
conversation directly — the user might just want to accept/decline
without opening the thread.
12.2 — Notification preferences
The user can mute each chat-related type independently:
GET /api/notifications/preferences
PUT /api/notifications/preferences
{
"preferences": [
{ "type": "message", "pushEnabled": false, "inAppEnabled": true, "emailEnabled": false },
{ "type": "message_request", "pushEnabled": true, "inAppEnabled": true, "emailEnabled": false },
{ "type": "message_request_accepted", "pushEnabled": true, "inAppEnabled": true, "emailEnabled": false }
]
}
Defaults: all three are push=true, inApp=true, email=false. Apply
prefs in the app's settings screen. The server gates push fan-out by
these prefs automatically — you don't need to suppress client-side.
13 — Block / unblock
A mutual block list lives at /api/blocks. Effects fan out across the
whole API — feed exposure, profile visibility, follow create, DM
create, mentions, search, discover. Mobile responsibilities:
- Provide a block / unblock affordance on the user profile screen and in the chat conversation menu.
- Surface a "Blocked accounts" list in Settings driven by
GET /api/blocks. - Drop locally-cached UI state for the blocked user on a successful block (their DM rows are soft-left server-side; the participation gate will 403 any send anyway, but cleaning up locally avoids stale UI).
13.1 — Block
POST /api/blocks/:userId
204 on success. Idempotent — re-blocking the same user returns 204. Side effects (one server-side transaction):
- Any
Followedges between the pair are deleted (both directions). - Every shared 1-on-1 DM has
leftAt = now()set on both users'ConversationParticipantrows. New sends from either side 403. Groups untouched (would leak the block to others). - Pending
follow_request/follow_accepted/follownotifications between the pair are deleted from both inboxes. - The blocked user's posts disappear from the blocker's feed pools.
- Both users see each other's profile as 404 going forward, with one
exception: the blocker can still see the blocked user via
GET /api/blocks(you must always be able to see who you blocked).
Comments / likes / mentions / reposts that existed before the block stay in place — only future actions are gated.
DELETE /api/blocks/:userId
- Removes the block row. Does NOT auto-resume follows or DM participation — the user must re-follow / re-create the DM going forward. Future follows / DMs / mentions work normally (subject to the usual privacy + request flows).
13.2 — List blocked users
GET /api/blocks?limit=30
GET /api/blocks?limit=30&cursor=<createdAtISO>
Cursor is an ISO timestamp from the previous nextCursor. Newest-first.
Bypasses the visibility gate — the blocker always sees this list even
though canViewProfile returns false for the same target elsewhere.
{
"success": true,
"data": {
"items": [
{
"id": "usr_x",
"username": "alice",
"profile": { "displayName": "Alice", "avatarUrl": "..." },
"blockedAt": "2026-06-18T12:34:56.789Z",
},
],
"nextCursor": "2026-06-18T12:34:56.789Z",
},
}
13.3 — UX notes
- In the requests tray, offer Block as a third menu option alongside
Accept and Delete (decline). Block auto-soft-leaves the DM, so you do
not need to also call
/declineafterwards. The PENDING row is still there butPOST /messageswill 403 on both sides — gone for practical purposes. To also remove the row, follow up with/decline(recipient side) or just rely on the participation gate. - After successful block: navigate the user back to their profile root or settings. Re-fetching the profile will 404.
- After unblock: the user is back to a clean stranger relationship —
refresh the profile fetch; private profiles will gate behind the
follow-request flow per
private-profile-implementation.md.
14 — Socket event reference (cheat sheet)
Client → Server
| Event | Payload | Effect |
|---|---|---|
join_conversation | conversationId: string | Join room, auto-mark unread as read |
leave_conversation | conversationId: string | Leave room |
typing_start | conversationId: string | Broadcast user_typing to room (excl. self) |
typing_stop | conversationId: string | Broadcast user_stopped_typing |
mark_read | { conversationId } | Mark unread receipts read; emit messages_read |
mark_delivered | { conversationId } | Mark undelivered receipts delivered |
refresh_auth | token: string (new access JWT) | Re-verify socket against new token; resets expiry timer |
Server → Client
| Event | Target | Payload |
|---|---|---|
new_message | conversation room | Full message + status. POST_SHARE rows carry sharedPostId only — resolve the card via POST /api/posts/share-cards (§5.10) |
chat_list_update | user personal room | { conversationId, status, lastMessage, unreadIncrement } (status: PENDING / ACCEPTED; increment = 0 for PENDING recipient) |
conversation_status_changed | user personal room | { conversationId, status: "ACCEPTED" } — request was accepted, flip tray |
conversation_declined | user personal room | { conversationId } — request hard-deleted, drop row |
message_status_update | sender personal room | { messageId, conversationId, status } |
message_delivered | sender personal room | { messageId, conversationId, userId, deliveredAt } |
messages_read | conversation room | { conversationId, readByUserId, readAt, isGroupChat, messageReadStatuses? } |
user_typing | conv room (excl self) | { userId, conversationId } |
user_stopped_typing | conv room (excl self) | { userId, conversationId } |
message_edited | conversation room | { messageId, conversationId, content, editedAt } |
message_deleted | conversation room | { messageId, conversationId } |
conversation_updated | conversation room | Full updated conversation object |
participant_updated | conversation room | { conversationId, participant } |
participant_removed | conversation room | { conversationId, userId } |
removed_from_conversation | removed user's room | { conversationId } |
participants_added | conversation room | { conversationId, participants } |
added_to_conversation | added user's room | { conversationId } |
unread_counts_init | connecting user | { counts: { [convId]: n }, inbox: { ... }, requests: { ... } } — counts = inbox (back-compat) |
auth_expiring | the socket | { expiresAt } — fired ~60s before token expiry |
auth_expired | the socket | { reason? } — fired at expiry; server disconnects immediately after |
15 — Open questions / not yet implemented
already on the radar — see
CHAT_IMPLEMENTATION_V2.mdfor status:
- Media in chat — text-only today. Image / file sending not wired.
- Voice / video calls — not implemented.
- Reactions — no emoji reactions.
- Message search — no full-text search yet.
- Production FCM/APNs adapter — push registration works, dispatch
works through the dispatcher, but the FCM channel is a stub. Once wired
(see
CHAT_IMPLEMENTATION_V2.md§6), pushes light up automatically. - Redis adapter for Socket.IO — single-server only today; multi-server WebSocket sync isn't wired.
16 — Quick implementation checklist
When wiring this into the mobile app, do the work in this order:
- Auth refresh + token storage — chat reuses your existing JWT.
- Register device — POST
/api/deviceson first launch + after each login; store the returned device id; DELETE on logout. - Socket connection — connect on app foreground / login, disconnect
on logout. Handle reconnect + listen for
auth_expiringto firerefresh_auth(see §4.5). - Conversation list screen — TWO TABS:
- Inbox: GET
/api/chat/conversations?folder=inbox&limit=30 - Requests: GET
/api/chat/conversations?folder=requests&limit=30Paginate vianextCursor. Listen forchat_list_update(route bystatusfield) +unread_counts_init(useinbox/requestsbuckets for tab badges) +conversation_status_changed(move row inbox ↔ requests) +conversation_declined(drop row).
- Inbox: GET
- Chat screen — emit
join_conversation, GET history withlimit=30, listen fornew_message/message_edited/message_deleted/messages_read/user_typing/user_stopped_typing. - Requests-tab actions — Accept (
POST /accept) / Decline (POST /decline) / Block (POST /api/blocks/:userId). After accept, navigate into chat. After decline / block, stay on requests tab and remove the row. - Send flow — UUID
clientMessageIdper message, optimistic append, POST with retry on transient errors, reconcile on socket / response. Note: in PENDING DMs the sender'sstatus.deliveredCountstays at 0 until the recipient accepts — surface as "Request pending" instead of "Sent". - Gap-fill — on socket reconnect, for each open conversation: GET
?afterSequence=<highestSeqSeen>. - Push handling — register notification listeners for three
data.entityTypecases:message(ormention) → open chat at the referenced messageconversationwithdata.deepLinkcontaining/requests/→ open requests tab, surface accept / declineconversationwithdata.deepLinkcontaining/chat/:id(not/requests/) →message_request_accepted— open chat normally
- Block / blocked-accounts screen — Settings → "Blocked accounts"
drives off
GET /api/blocks. Per-row Unblock firesDELETE /api/blocks/:userId. Profile screen gets a Block action in the overflow menu (POST /api/blocks/:userId). - Group management screens (later) — add / remove members, promote / demote, lock messaging.