Skip to main content
Version: 1.0

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: mobile on every request.
  • Auth: Authorization: Bearer <accessToken> on every endpoint below.
  • WebSocket auth: pass JWT in the handshake auth.token field (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) → show name or fall back to a comma-joined member list.
  • Unread badge → unreadCount. The socket pushes deltas via chat_list_update; trust the HTTP value on every refetch.
  • Requests badge → drive from the requests bucket in unread_counts_init (see §4.4) or GET /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.
  • Auto-accept on dedup: if a PENDING DM already exists and the recipient (not the requester) calls POST /conversations for that pair after the recipient has come to follow the requester, the server flips the row to ACCEPTED in-line and fires conversation_status_changed to 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 Block exists 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.token and 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-deliver unread_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 counts keep working — it carries the inbox bucket only.
  • It's safe to also call GET /api/chat/conversations?folder=... — per-row unreadCount is 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:

codemeaning
403Not a participant / removed / messagingRestricted member
404Conversation not found
429Rate 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 id server 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: true instead of creating a duplicate.
  • Don't reuse a clientMessageId for 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)

  1. On tap-send — append a local message with id: "temp-<timestamp>", status: "pending", your clientMessageId. Show clock icon.
  2. Fire POST in the background. Do not block the input on the response.
  3. On new_message socket event — if it's your own message (sender matches you), reconcile by clientMessageId → replace the temp row.
  4. On HTTP success — if the socket beat you to it (race), the temp row is already gone. Otherwise, replace temp with the returned row.
  5. On HTTP error after retries — flip status to failed, surface "Tap to retry". User can retry with the same clientMessageId.

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_edited to 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 status aggregate stays at deliveredCount=0, readCount=0 until 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 sets deliveredAt = acceptTime on each. The sender's status aggregate jumps to allDelivered=true after the next message_delivered socket event or GET /messages/:id/info call.

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" and sharedPostId, but no card body — resolve it (see below).
  • postId and media are mutually exclusive → 400 if both sent.
  • 404 if the post is missing/hidden or you can't view it (can't share what you can't see).
  • A PostShare row (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:

  • HistoryGET /api/chat/conversations/:id/messages already inlines sharedPost on each POST_SHARE row.

  • Real-time — on a new_message with type: "POST_SHARE", batch-resolve via:

    POST /api/posts/share-cards
    { "postIds": ["pst_abc", "pst_def"] } // dedupe across the visible page

    Returns data.cards in 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_changed socket event fires to both participants.
  • The requester receives a message_request_accepted notification with data.conversationId. Deep-link straight into the chat.
  • All MessageReceipt rows the requester was missing are backfilled with deliveredAt = now. The next message_delivered socket 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_declined socket 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 surface Block (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 mention notification (type: "mention").
  • The generic message notification 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).

codemeaning
401Missing or invalid Authorization / socket auth.token
403Not a participant / removed / messagingRestricted / not connected / BLOCKED / not the recipient (accept/decline)
404Conversation, message, participant not found, or conversation no longer PENDING (decline)
429Rate limit (60 messages/min/user; 100 mentions/24h)
500Server error — capture request_id if present

12 — Push notifications (mobile background)

There are now three chat-related notification types you must route:

typeWhen it firesDeep-link
messageNew 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_requestFirst / subsequent message from a non-follower in a PENDING DM, to the recipientksn://chat/requests/:conversationId
message_request_acceptedRecipient tapped Accept on a request the user sentksn://chat/:conversationId
mention@username mention in a group chatksn://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",
},
}

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 Follow edges between the pair are deleted (both directions).
  • Every shared 1-on-1 DM has leftAt = now() set on both users' ConversationParticipant rows. New sends from either side 403. Groups untouched (would leak the block to others).
  • Pending follow_request / follow_accepted / follow notifications 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
  1. 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 /decline afterwards. The PENDING row is still there but POST /messages will 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

EventPayloadEffect
join_conversationconversationId: stringJoin room, auto-mark unread as read
leave_conversationconversationId: stringLeave room
typing_startconversationId: stringBroadcast user_typing to room (excl. self)
typing_stopconversationId: stringBroadcast user_stopped_typing
mark_read{ conversationId }Mark unread receipts read; emit messages_read
mark_delivered{ conversationId }Mark undelivered receipts delivered
refresh_authtoken: string (new access JWT)Re-verify socket against new token; resets expiry timer

Server → Client

EventTargetPayload
new_messageconversation roomFull message + status. POST_SHARE rows carry sharedPostId only — resolve the card via POST /api/posts/share-cards (§5.10)
chat_list_updateuser personal room{ conversationId, status, lastMessage, unreadIncrement } (status: PENDING / ACCEPTED; increment = 0 for PENDING recipient)
conversation_status_changeduser personal room{ conversationId, status: "ACCEPTED" } — request was accepted, flip tray
conversation_declineduser personal room{ conversationId } — request hard-deleted, drop row
message_status_updatesender personal room{ messageId, conversationId, status }
message_deliveredsender personal room{ messageId, conversationId, userId, deliveredAt }
messages_readconversation room{ conversationId, readByUserId, readAt, isGroupChat, messageReadStatuses? }
user_typingconv room (excl self){ userId, conversationId }
user_stopped_typingconv room (excl self){ userId, conversationId }
message_editedconversation room{ messageId, conversationId, content, editedAt }
message_deletedconversation room{ messageId, conversationId }
conversation_updatedconversation roomFull updated conversation object
participant_updatedconversation room{ conversationId, participant }
participant_removedconversation room{ conversationId, userId }
removed_from_conversationremoved user's room{ conversationId }
participants_addedconversation room{ conversationId, participants }
added_to_conversationadded user's room{ conversationId }
unread_counts_initconnecting user{ counts: { [convId]: n }, inbox: { ... }, requests: { ... } }counts = inbox (back-compat)
auth_expiringthe socket{ expiresAt } — fired ~60s before token expiry
auth_expiredthe socket{ reason? } — fired at expiry; server disconnects immediately after

15 — Open questions / not yet implemented

already on the radar — see CHAT_IMPLEMENTATION_V2.md for 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:

  1. Auth refresh + token storage — chat reuses your existing JWT.
  2. Register device — POST /api/devices on first launch + after each login; store the returned device id; DELETE on logout.
  3. Socket connection — connect on app foreground / login, disconnect on logout. Handle reconnect + listen for auth_expiring to fire refresh_auth (see §4.5).
  4. Conversation list screen — TWO TABS:
    • Inbox: GET /api/chat/conversations?folder=inbox&limit=30
    • Requests: GET /api/chat/conversations?folder=requests&limit=30 Paginate via nextCursor. Listen for chat_list_update (route by status field) + unread_counts_init (use inbox / requests buckets for tab badges) + conversation_status_changed (move row inbox ↔ requests) + conversation_declined (drop row).
  5. Chat screen — emit join_conversation, GET history with limit=30, listen for new_message / message_edited / message_deleted / messages_read / user_typing / user_stopped_typing.
  6. 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.
  7. Send flow — UUID clientMessageId per message, optimistic append, POST with retry on transient errors, reconcile on socket / response. Note: in PENDING DMs the sender's status.deliveredCount stays at 0 until the recipient accepts — surface as "Request pending" instead of "Sent".
  8. Gap-fill — on socket reconnect, for each open conversation: GET ?afterSequence=<highestSeqSeen>.
  9. Push handling — register notification listeners for three data.entityType cases:
    • message (or mention) → open chat at the referenced message
    • conversation with data.deepLink containing /requests/ → open requests tab, surface accept / decline
    • conversation with data.deepLink containing /chat/:id (not /requests/) → message_request_accepted — open chat normally
  10. Block / blocked-accounts screen — Settings → "Blocked accounts" drives off GET /api/blocks. Per-row Unblock fires DELETE /api/blocks/:userId. Profile screen gets a Block action in the overflow menu (POST /api/blocks/:userId).
  11. Group management screens (later) — add / remove members, promote / demote, lock messaging.