Skip to main content
Version: Latest
UPDATED 2026-09-09 pendingPostRequest now carries selectedOptions — the options the requester ticked when they submitted. Show them on the chat request card the same way the author's Responses tray does; the array is [] when nothing was ticked.
- **2026-09-09** — `pendingPostRequest` now carries `selectedOptions` — the options the requester ticked when they submitted. Show them on the chat request card the same way the author's Responses tray does; the array is `[]` when nothing was ticked.- **2026-09-01** — A group you left stays in your conversation list, read-only rather than disappearing (section 2.1a). Blocked DMs are the exception — they still vanish for both sides.
```jsonc"pendingPostRequest": { "id": "req_x", "postId": "post_y" } // or null"pendingPostRequest": { "id": "req_x", "postId": "post_y", "selectedOptions": ["Frontend", "Design"] // what the requester ticked; [] when none} // or null```

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).

What's new

  • 2026-09-09pendingPostRequest now carries selectedOptions — the options the requester ticked when they submitted. Show them on the chat request card the same way the author's Responses tray does; the array is [] when nothing was ticked.
  • 2026-09-01 — A group you left stays in your conversation list, read-only rather than disappearing (section 2.1a). Blocked DMs are the exception — they still vanish for both sides.

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)
GET /api/chat/conversations?folder=requests&q=<term>
→ filter a tray (groups by name, DMs/requests by the other person)
1b. GET /api/chat/search?q=<term> → search existing chats + startable contacts (new-message screen)
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/clear → recipient dismisses ("deletes") a request — their
tray only; sender keeps theirs and may write again
POST /api/blocks/:userId → block: soft-leaves the DM for both sides
4. PATCH /api/chat/conversations/:id → name / image / description / lock messaging (admin)
GET /api/chat/conversations/:id → one conversation: group info + members
GET /api/chat/conversations/:id/media → shared content, newest first (cursor)
POST /api/chat/conversations/:id/leave → leave a group (admin leaving auto-promotes)
DELETE /api/chat/conversations/:id → delete a group, for everyone (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; a PENDING
request is text-only — no media, GIF or postId)
7. POST /api/chat/conversations/:id/read → mark all read
PATCH /api/chat/conversations/:id/mute → mute/unmute for you (duration: 1h|8h|1d|1w|always|off)
POST /api/chat/conversations/:id/clear → "delete chat" for you only (§3.3)
8. PATCH /api/chat/messages/:messageId → edit (15-min window)
DELETE /api/chat/messages/:messageId → soft delete (one)
POST /api/chat/messages/bulk-delete → soft delete many (your own, ≤100)
GET /api/chat/messages/:messageId/info → per-recipient receipts (sender only)
9. POST /api/chat/conversations/:cid/participants
→ add members (group, admin only)
DELETE /api/chat/conversations/:cid/participants/:userId
→ remove member (soft, admin only)
PATCH /api/chat/conversations/:cid/participants/:userId
→ promote/demote — DISABLED, 403 for everyone (§8)
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 / delete / block actions (see §6). A dismissed request leaves this tray and returns if the sender writes again.

2.1a — The three trays

The server decides which tray a row belongs to. Never derive it on the client.

TrayMeaningWhere it comes from
General Inboxconversations I can take part in — plus, since 2026-09-01, groups I left or was removed from, read-only (see below)GET /api/chat/conversations?folder=inbox
Requestssomeone is asking me to start a conversation — except post requestsGET /api/chat/conversations?folder=requests
Responsespeople responding to my intent postsGET /api/posts/requests/inbox

A row carrying viewerLeft is read-only. A group you left or were removed from stays in the inbox, frozen at viewerLeft.at, ending in the system line that says why (reason is "removed", "left", or null for an old thread). Hide the composer and every action whenever the field is present — absence means an ordinary writable chat, so presence is the whole test. POST /conversations/:id/clear dismisses the row. Full contract: mobile-chat-groups-guide.md §8. A blocked DM is not this — it stays out of both people's lists entirely and never carries viewerLeft.

A post request is a real conversation from the moment it is sent. Submitting a request against a CTA post (POST /api/posts/:id/requests) opens the DM immediately and seeds it with one POST_SHARE message carrying the note and the post card. So:

  • the requester sees it in their General Inbox straight away, ordered by updatedAt among their other chats — nothing to fetch separately, nothing to merge, nothing to sort;
  • the post author sees it in Responses, and only there for as long as the thread is nothing but the request. The moment the requester sends a message of their own, the row also appears in the author's Requests tray carrying the whole history — post card, note and the new message — with the usual Accept / Delete / Block;
  • on accept the same conversation flips to ACCEPTED and carries on. No second conversation is ever created — one user-to-user relationship has exactly one conversation, always.

Rows carrying an unanswered request are marked, so the client needs no rules of its own:

"pendingPostRequest": {
"id": "req_x",
"postId": "post_y",
"selectedOptions": ["Frontend", "Design"] // what the requester ticked; [] when none
} // or null
if (conv.pendingPostRequest) {
// Banner: "Awaiting a response to your post". Tapping the card opens the post.
// NOT a composer lock — see below.
}

Changed 2026-08-31. pendingPostRequest used to mean "nobody may send" and both sides got 403 "Waiting for the author to accept your request". That lock is gone and so is that error. The field is now provenance only.

Routing needs no derivation either: chat_list_update carries tray ("inbox" | "requests"), computed by the server for the recipient it is sent to, alongside pendingPostRequest. A row arriving over the socket can be placed and rendered without a fetch — see the socket guide's event table.

Composer state comes from status + requesterId, for every conversation, whatever it grew out of:

if (conv.status === "ACCEPTED") full(); // text + media + GIF + share
else if (conv.requesterId === me) textOnly(); // my outgoing request, cold or post
else locked(); // Accept / Delete / Block

So a post requester keeps writing into their own request before the author answers, exactly as they would into a cold message request — text only (media[] / postId are 403 until accepted).

One event, two views. If the requester and author already had a chat, the request appends to that thread instead of opening a new one. The author then legitimately sees it in General Inbox (a new message in an ongoing conversation) and in Responses (a response to their post). That is not a duplicate — the two views mean different things.

Full request lifecycle, CTA states and the author's side: ../post/mobile-post-requests-guide.md and ../post/mobile-post-request-dm-guide.md.

{
"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.

2.2 — Filtering a tray (q)

Add q=<term> to GET /api/chat/conversations to filter the current folder in place — same shape, same cursor pagination, just fewer rows.

GET /api/chat/conversations?folder=requests&q=alex // find a request by who sent it
GET /api/chat/conversations?folder=inbox&q=study // find a chat in the main tray
  • Groups match by their name; DMs (and requests, which are DMs) match by the other participant's username / displayName. A group member's name does not pull in a group.
  • Lenient: q is trimmed and applied only when ≥ 2 chars — a shorter/empty q is ignored and you get the full tray (no error). Use it to back a search box inside each tab.
  • unreadCount, nextCursor, and folder scoping are unchanged — q only narrows the set.

2.3 — Search: existing chats + startable contacts (new-message screen)

GET /api/chat/search?q=<term> powers a "new message" search: it returns both the chats you already have and the people you can start one with, in a single call.

GET /api/chat/search?q=ale
{
"success": true,
"data": {
"conversations": [ /* same shape as the list rows, each with unreadCount */ ],
"users": [ /* startable contacts: { id, username, profile } */ ]
}
}
  • conversations — chats you're in (both trays), matched the same way as §2.2 (groups by name, DMs by the other person). Tap → open the chat.
  • users — people you follow (accepted) that you don't have a DM with yet. Tap → POST /api/chat/conversations (§3) to start the chat. Blocked users are excluded; people who follow you but you don't follow back are not included.
  • q must be ≥ 2 chars (trimmed) — shorter returns 400. Rate-limited (30 req / 10s). No cursor — capped at 20 per bucket (typeahead, not a full list). Debounce keystrokes ~250ms.

Message body search (finding a chat by words inside its messages) is a separate, not-yet-built feature — see §15. q here matches names/titles only.


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 (admin only)

PATCH /api/chat/conversations/:id
{
"name": "New name", // optional
"imageUrl": "https://…/groups/x.jpg", // optional, group only. null clears it
"description": "Weekend football", // optional, group only, ≤500. null clears it
"messagingRestricted": true // optional — when true, only the admin can send
}

When messagingRestricted: true, members trying to send get a 403.

imageUrl is a two-step upload, same as an avatar: presign → PUT the bytes → PATCH with the returned public URL. The server byte-verifies it (a non-image, or a URL outside our storage, is 415), and replacing or clearing the image deletes the old object for you. description is plain text.

Omit a key to leave it alone; send null to clear imageUrl / description. Both are group-only — 400 on a DM.

One admin per group. The admin is whoever created it (role: "OWNER" on their participant row). There is no second admin and no way to grant the role — see the group guide. Show the settings UI only when the viewer's own row has role === "OWNER".

3.2 — Mute / unmute a conversation (per-user, any member)

Silences a DM or group for you only — a personal setting, not visible to other participants. Works on both DMs and groups; any active member can mute.

PATCH /api/chat/conversations/:id/mute
{ "duration": "1h" | "8h" | "1d" | "1w" | "always" | "off" }
// 200 — mirrors the stored state (also arrives on the socket, see §14)
{ "success": true, "data": {
"conversationId": "…",
"mutedUntil": "2026-08-03T13:10:22.615Z", // absolute instant; null when off
"isMuted": true // convenience: mutedUntil > now
} }
  • What it does: stops the generic message / reply push (and the in-app notification) for that chat until mutedUntil. @mentions still notify — a group mention pierces the mute.
  • always = muted until you turn it off (stored as a far-future instant). off = clear (mutedUntil: null). Re-muting overwrites.
  • Timed mutes auto-expire server-side — no unmute call needed; a past mutedUntil simply stops suppressing.
  • Not muted: messages still arrive over the socket, and the conversation's unreadCount still increments. Mute is about notifications, not delivery — grey the row / drop it from a global badge, but keep showing per-chat unread.
  • Non-member or a chat you've left → 404. Bad duration422/400.

Reading mute state for a chat: it rides your own participant row in the list (and create / patch) responses — no separate field on the envelope:

const me = conv.participants.find((p) => p.userId === myUserId);
const isMuted = !!me?.mutedUntil && new Date(me.mutedUntil) > new Date();

Keep it in sync live via the conversation_muted socket event (§14) — fired to your own devices when any of them mutes/unmutes, so a second device updates without a refetch.

3.3 — Delete a chat (for you only)

The swipe-to-delete on a chat row. Works on DMs and groups.

POST /api/chat/conversations/:id/clear
{ "success": true, "data": { "conversationId": "cnv_x", "clearedUpToSequence": 42 } }

Everything up to sequenceNumber: 42 is now invisible to you: the row leaves your list and your search results, your history starts empty, the shared media gallery drops those attachments, and the unread badge goes to 0. Nothing is deleted, and the other participants are not affected or told.

The chat comes back the moment anything newer arrives — a message from them, or one you send yourself — showing only messages after the boundary. So don't treat the row as gone forever: on new_message for a conversation missing from your cached list, re-insert it (or just refetch the list).

Delete chatLeave group (§8)
You stay a memberYou stop being a member
Nobody else is toldEveryone sees participant_removed
Notifications keep arrivingThey stop
Returns on any newer messageNever returns
Works on DMs tooGroups only

⚠️ Don't label it "Delete for everyone". The other person keeps their full copy of the conversation and can keep messaging you. "Delete chat" (WhatsApp's wording) is accurate; "Delete conversation permanently" is not.

Fires conversation_cleared ({ conversationId, clearedUpToSequence }) to your own devices only, so a second device hides the row without a refetch.

Two edges worth knowing:

  • Clearing again later moves the boundary forward; clearing twice with nothing in between is a no-op.
  • A reply that quotes a message you cleared will still show that quote in its preview if it arrives live over the socket — one payload goes to the whole room. Reload the history and the quote is gone. Harmless, but don't be surprised by it.

4 — WebSocket connection (real-time)

Deep dive: mobile-chat-socket-guide.md is the dedicated socket reference — the full room model, every event you emit and every event you listen for (with when + why), the receipt lifecycle, and a drop-in RN client wrapper. This section is the quick-start; reach for that doc when wiring the transport layer.

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 }) => {
// "token_expired" → the token just ran out: refresh, then reconnect.
// "invalid_token" → never valid (bad signature/malformed): go to login.
// "identity_mismatch" → token is for another user: reconnect as them.
// "no_exp_claim" → token has no exp: get one from the auth endpoints.
if (reason === "token_expired") return refreshAndReconnect();
goToLogin();
});

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, or dismissed)

// 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.
});

// A GROUP was hard-deleted (by its admin, or because the last member left).
// Fires to every member. Drop it from local caches. DMs and requests never
// produce this — dismissing a request is `conversation_cleared` below, and it
// reaches your own devices only.
socket.on("conversation_deleted", ({ conversationId }) => {
// Drop the row and any open chat-room subscription for this id.
});

// YOU dismissed a request (or cleared a chat) — on this device or another.
// Private to you: the sender is never told. Hide the row; it returns on the
// next message they send.
socket.on("conversation_cleared", ({ conversationId, clearedUpToSequence }) => {
// Remove from the requests tray (or inbox) on your other devices too.
});

// `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.
},
);

Key the handler on readByUserId. Every messages_read event carries the same shape no matter how the read was triggered — socket mark_read / join_conversation or the HTTP POST /read endpoint (§5.6). Both emit { conversationId, readByUserId, readAt, isGroupChat, messageReadStatuses? }. (Older server builds emitted userId instead of readByUserId from the HTTP path — if your client special-cased that, drop the workaround; it's unified now.)

5.5.1 — Don't clobber receipts on reconcile (why ticks "only show on refresh")

When you reconcile your own message — the new_message echo of something you sent, or a refetch of a media message after its upload — merge it into the cached row. Do not replace the row wholesale.

Media sends are slower than text: the bytes upload first, so the send round-trip (and its new_message echo) can land after a message_delivered / messages_read for that same message has already advanced its status. If your reconcile step overwrites the cached row with the freshly-sent object (whose status still reads deliveredCount: 0, readCount: 0), you wipe the receipt you already applied — and it only reappears on the next history refetch. This is the #1 cause of read/delivery ticks looking stuck on image / file messages while text works.

// reconcile own message: keep the more-advanced status
function mergeOwn(cached: Message | undefined, incoming: Message): Message {
if (!cached?.status || cached.status.type !== "sent") return incoming;
const inc = incoming.status;
if (!inc || inc.type !== "sent") return { ...incoming, status: cached.status };
return {
...incoming,
status: {
...inc,
// never regress a count we already advanced from a receipt event
deliveredCount: Math.max(inc.deliveredCount, cached.status.deliveredCount),
readCount: Math.max(inc.readCount, cached.status.readCount),
allDelivered: inc.allDelivered || cached.status.allDelivered,
allRead: inc.allRead || cached.status.allRead,
},
};
}

The same rule applies to the async image-variant refetch (media guide § "Thumbnails / variants") — swap in the new media[].variants, keep the live status.

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 the same messages_read payload to the conversation room (§5.5) — the HTTP path is just a socket-less way to fire the identical event. Pick one per trigger; don't call both for the same read.

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.8.1 — Delete many at once (bulk)

POST /api/chat/messages/bulk-delete
{ "messageIds": ["msg_a", "msg_b", "msg_c"] } // your own only, 1–100

Same per-message effect as the single delete (content redacted, isDeleted=true, mentions dropped, media purged) applied to the whole batch.

  • All-or-nothing. If any id is unknown → 404; if any belongs to another user → 403. Nothing is deleted on rejection — fix the list and retry. So only send ids the current user actually sent.
  • Emits one message_deleted per message (reuse the §5.8 handler — no new event). Messages may span conversations; each fires to its own room.
  • Duplicate ids are de-duped server-side. Cap is 100 per request.
{ "success": true, "data": { "deletedIds": ["msg_a", "msg_b", "msg_c"] } }

Use this to back a multi-select "delete selected" action. Filter the selection to the user's own messages client-side first — the server rejects the whole batch otherwise.


5.9 — Sending into a PENDING DM (request thread)

The sender can keep writing into a PENDING thread — there is no per-thread message cap — but text only until it is accepted (§6.4.1): media[] and postId are both 403. Two more 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.


System messages: lifecycle events (a request opened or accepted, members added or removed, a group renamed) now arrive as ordinary messages with type: "SYSTEM". Render system.text, never content, and treat them as non-interactive — see mobile-chat-system-messages-guide.md.

6 — Message requests (accept / delete / block)

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.

A request has exactly three answers, and every row exposes all three:

ActionCallResult
AcceptPOST /api/chat/conversations/:id/acceptRow moves to the inbox, composer opens
DeletePOST /api/chat/conversations/:id/clearRow leaves your tray; the sender is untouched
BlockPOST /api/blocks/:userIdBoth sides soft-leave the DM, and the pair is blocked

There is no fourth path and no implicit one. In particular there is no composer on a PENDING thread — see §6.4.

Nothing here destroys the sender's copy. Delete and Block both act on your own side of the conversation; the sender's row, their message and their ability to write are unaffected by Delete, and merely gated by Block. Only a group admin can hard-delete anything, and only a group.

On the sending side there is one restriction: a request carries text only until it is accepted — no media, no GIFs, no shared posts. See §6.4.1.

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.

Only after this returns should the composer become usable. Sending before it lands is a 403 (§6.4).

6.1.1 — Two ways a request gets accepted

The explicit tap is the one you build. There is a second path you will see fire without a tap, and it lands in identical state — status: "ACCEPTED", receipts backfilled, conversation_status_changed to both participants, and a message_request_accepted notification to the requester — so don't special-case it.

#TriggerNotes
1Recipient taps Accept (POST …/accept)The explicit path
2Recipient opens the thread via POST /conversations after following the requester backFollowing someone means you'll accept their DMs; the request should never have been in the tray. Opening alone does nothing — the follow-back is what satisfies the gate

What does not accept a request:

  • Replying. The recipient cannot send into a PENDING thread at all — the server 403s it (§6.4). This changed: replying used to accept implicitly.
  • Reading it. GET …/messages leaves it PENDING — peeking is not consenting.
  • Opening it without having followed back. Path 2 requires you to follow them; the sender following you is what got them into your tray in the first place and grants nothing further.
  • The requester sending more messages. Nobody accepts their own request.

6.2 — Delete (dismiss)

POST /api/chat/conversations/:id/clear

Delete is one-sided. It empties your tray. It does not touch the sender.

  • 200 with { conversationId, clearedUpToSequence }.
  • The request leaves your requests tray, and your history of it starts empty.
  • The sender keeps their copy, still sees the message they sent, and may send again. To them a dismissed request is indistinguishable from one you never opened — which is the point.
  • The request comes back when they write again. The new message lands in your tray as a fresh request, showing only what arrived after you dismissed.
  • 404 if you're not an active participant.
  • Emits conversation_cleared to your own devices only — never to the sender. A second device hides the row without a refetch.

This is the same endpoint as "delete chat" on a normal conversation (§3.3), because it is the same operation: a boundary on your own membership row. Nothing about a request is ever hard-deleted.

⚠️ DELETE /api/chat/conversations/:id is not this. That route deletes a group for every member and returns 400 on any DM, request or not. Do not wire the Delete button to it.

6.3 — Block

POST /api/blocks/:userId
    1. Blocks both directions, drops follow edges either way, and deletes pending follow notifications between the pair (§13).
  • The shared DM — request or accepted — is soft-left by both sides: leftAt is set on both participant rows, so the row leaves both lists and any send 403s. Nothing is deleted.

Block is enough on its own. You don't need to dismiss first, and you can't afterwards — you're no longer an active participant, so /clear would 404.

6.4 — No composer on a pending thread

POST /api/chat/conversations/:id/messages → 403
{ "error": "Accept this message request before replying" }

The recipient of a PENDING request cannot send into it. Keep the composer hidden or disabled behind the three buttons until status === "ACCEPTED"; the 403 is the backstop, not the mechanism.

The requester may keep sending while it's pending — but text only (§6.4.1). This holds whatever the request grew out of: a post request's requester writes into their own thread on exactly these terms (there is no separate post-request lock — §2.1a).

While PENDING, no MessageReceipt rows exist at all, so the requester's ticks stay at 0 no matter what the recipient does. That is deliberate: a stranger must not learn whether their message was delivered or read. Accepting backfills them in one go.

6.4.1 — A request is text-only (sender side)

Until the recipient accepts, the requester may send plain text and nothing else. Everything the composer offers beyond the text field is refused:

POST /api/chat/conversations/:id/messages → 403
{ "error": "Only text messages can be sent in a message request" }
Body carriesWhile PENDING
content only✅ sends
content + parentMessageId (quoted reply)✅ sends — still a text message
media[] — image, file, or GIF (remoteUrl)❌ 403
postId — native shared-post card❌ 403

A stranger should not be able to push an image or a post card into someone's tray, where the preview renders before any decision has been made. Text is legible, reportable, and easy to ignore.

Hide the attachment, GIF and share affordances on a pending outgoing request — leave only the text field and Send. The 403 is the backstop, not the mechanism, and the client should never let a user pick an image and then lose it to an error.

The restriction lifts the moment the request is accepted (status flips to "ACCEPTED" — you'll see it on conversation_status_changed, §4.6). Restore the full composer then.

Groups and accepted DMs are unaffected — this applies only to a PENDING DM, and it applies to every PENDING DM: a thread opened by a post request is a PENDING DM like any other, so pendingPostRequest changes nothing about who may send.

6.5 — UI suggestions

  • Stack the request rows with the actor's avatar + the first ~60 characters of the latest message as a preview.
  • Render the three actions as one row: Accept · Delete · Block. Delete is recoverable in practice (the sender can write again and the row returns), so a confirm there is optional; Block is worth confirming.
  • Open the thread read-only. Show the three buttons where the composer would be; swap in the real composer on conversation_status_changed.
  • After accept, stay in the conversation. After delete or block, pop back to the requests tab and remove the row. Don't show "deleted forever" copy on Delete — the request can reappear, and it will look like a bug if you claimed otherwise.
  • Drive all of it from conversation.status + requesterId off the server, not from local state — the same account on another device gets the same socket events and must land in the same place.

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

Group conversations have their own guide: mobile-chat-groups-guide.md — the single-admin model, group name / image / description, adding and removing members (and the gates that reject an add), admin-only messaging, leaving with its auto-promotion rule, deleting, group info, shared content, and reporting a message or a group.

The short version, so this guide stands alone:

NeedCall
Group info + membersGET /api/chat/conversations/:id
Name / image / description / lock messagingPATCH /api/chat/conversations/:id (admin)
Add membersPOST /api/chat/conversations/:cid/participants (admin)
Remove a memberDELETE /api/chat/conversations/:cid/participants/:userId (admin)
LeavePOST /api/chat/conversations/:id/leave
Delete the groupDELETE /api/chat/conversations/:id (admin)
Shared contentGET /api/chat/conversations/:id/media

Three things that catch people out, covered in full in the group guide:

  • One admin per group — the creator. Detect it as role === "OWNER"; "ADMIN" is a legacy value the server ignores. Promote/demote is disabled (403), so don't build that UI.
  • The admin can leave without handing over. The longest-standing remaining member is auto-promoted and participant_updated fires; the last member out deletes the group and everyone gets conversation_deleted.
  • Adding members runs the same follow/block gate as creating, plus the 50-participant cap — one bad invitee rejects the whole batch.

Group socket events (participants_added, participant_removed, participant_updated, added_to_conversation, removed_from_conversation, conversation_deleted) are in §14 and in the group guide.


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=. Adding q (§2.2) keeps the same cursor — it walks the filtered set. GET /api/chat/search (§2.3) is not paginated (capped at 20 per bucket, no cursor).


11 — Error responses

Validation errors → HTTP 400 with details (same shape as posts guide §12).

codemeaning
401Missing or invalid Authorization / socket auth.token
400DELETE /conversations/:id on a DM (groups only — dismiss with /clear) or a malformed body
403Not a participant / removed / messagingRestricted / not connected / BLOCKED / not the group admin / not the recipient (accept) / replying to a PENDING request / attaching media or a shared post to one
404Conversation, message, or participant not found; or /clear when you are no longer an active participant
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, delete or block 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, Block is the third of the three actions alongside Accept and Delete (§6). Call it on its own: the shared DM is soft-left by both sides, so the row is already gone from your tray — and you can't dismiss it afterwards, since /clear 404s once you're no longer an active participant.
  • A request and an accepted DM are treated the same by a block: both are soft-left, nothing is deleted, POST /messages 403s on both sides. The sender's copy of a request they sent is not destroyed — blocking gates them, it doesn't erase them.
  • 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)

Full event semantics + an RN client wrapper live in mobile-chat-socket-guide.md. This table is the at-a-glance version.

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, requesterId, pendingPostRequest, tray, lastMessage, unreadIncrement }tray is "inbox" | "requests", computed per recipient; place the row by it. increment = 0 for a PENDING recipient
conversation_status_changeduser personal room{ conversationId, status: "ACCEPTED" } — request was accepted, flip tray
conversation_deletedeach member's personal room{ conversationId } — a group was hard-deleted (admin, or last member left); drop the row. Never fires for a DM or a request
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 } — one per message (bulk delete fires N of these, §5.8.1)
conversation_updatedconversation roomFull updated conversation object
conversation_mutedyour own personal room{ conversationId, mutedUntil, isMuted } — you muted/unmuted (from any device); sync mute state (§3.2)
conversation_clearedyour own personal room{ conversationId, clearedUpToSequence } — you deleted the chat on another device; hide the row (§3.3)
participant_updatedconversation room{ conversationId, participant } — a member was auto-promoted to admin after the admin left (group guide §6)
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 — ✅ shipped: images / GIFs / files. See mobile-chat-media-guide.md.
  • Reactions + GIF picker — ✅ shipped. See mobile-chat-reactions-gifs-guide.md.
  • Threaded replies — ✅ shipped. See mobile-chat-replies-guide.md.
  • Groups — ✅ shipped: single admin, group image / description, add / remove, leave with auto-promotion, delete, group info, shared content, reporting. See mobile-chat-groups-guide.md. Not built: a deliberate admin transfer, and a second admin (promote/demote is disabled).
  • Voice / video calls — not implemented.
  • Chat + contact search — ✅ shipped: GET /api/chat/search (existing chats + startable contacts, §2.3) and the q filter on the conversation list (§2.2). What's not built is message body full-text search (finding a chat by words typed inside its messages).
  • 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 its tray field — the server's own answer; status alone cannot place a post-request row) + unread_counts_init (use inbox / requests buckets for tab badges) + conversation_status_changed (move row inbox ↔ requests) + conversation_cleared (you dismissed a row on another device) + conversation_deleted (a group was deleted).
    • Search box: add &q=<term> to filter the active tab (§2.2), or wire a dedicated "new message" screen off GET /api/chat/search (§2.3, chats + startable contacts). Debounce ~250ms; require ≥ 2 chars.
  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 — the three answers, and only these three (the tab can also hold a post-request thread once the requester has written into it; the actions are identical, and Accept opens the chat without answering the post request itself — that stays in Responses): Accept (POST /accept) / Delete (POST /conversations/:id/clear) / Block (POST /api/blocks/:userId). Delete is one-sided — it empties your tray and the sender never knows; the request returns if they write again. Do not wire Delete to DELETE /conversations/:id (groups only, 400 on a DM). After accept, navigate into chat; after delete or block, stay on the requests tab and remove the row. No composer on a PENDING thread — the server 403s a reply (§6.4). Drive the buttons off conversation.status + requesterId from the server, never off local state.
  7. Send flow — UUID clientMessageId per message, optimistic append, POST with retry on transient errors, reconcile on socket / response. Two PENDING-DM notes for the sender: status.deliveredCount stays at 0 until the recipient accepts (surface as "Request pending", not "Sent"), and the composer must offer text only — hide the attachment, GIF and share buttons until status === "ACCEPTED" (§6.4.1).
  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 / delete / block
    • 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 and lock messaging, shown only to the single admin (the creator). No promote / demote — see mobile-chat-groups-guide.md for the full contract.
⤓ Download .md