Mobile Notifications — Integration Guide
For mobile development. Everything needed to render the notification screen — the flat list, the grouped list ("Alice and 3 others liked your post"), read state, the badge, and live updates over the socket. No backend changes needed on your side.
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>on every request. - Send
X-Client-Type: mobileon every request.
Short version — just the calls
1. GET /api/notifications/grouped → the notification screen (collapsed)
2. GET /api/notifications → the same events, flat, one row each
3. GET /api/notifications/unread-count→ the badge
4. PATCH /api/notifications/read → mark one whole group read { groupKey }
5. PATCH /api/notifications/:id/read → mark one row read
6. PATCH /api/notifications/read-all → mark everything read
7. DELETE /api/notifications/:id → delete one row
8. socket "notification" → a new event arrived (carries groupKey)
Build the screen on (1). GET /api/notifications still exists, unchanged, and
returns exactly what it always did — use it if you want an ungrouped feed.
1. The grouped list
GET /api/notifications/grouped?limit=20&cursor=<opaque>
limit is clamped server-side to 1–50 (default 20).
{
"success": true,
"data": {
"groups": [
{
"groupKey": "like:post:019fffb5-0bb3-757c-a2f9-9168f03bad17",
"type": "like",
"entityType": "post",
"entityId": "019fffb5-0bb3-757c-a2f9-9168f03bad17",
"count": 2,
"unreadCount": 2,
"partial": false,
"latestAt": "2026-08-21T11:55:29.883Z",
"latest": {
"id": "01a0242d-16da-7350-8705-f942f5d7bbef",
"type": "like",
"title": "Charlie Brown liked your post",
"body": null,
"data": {
"actorId": "cml805qs50004iaqhu8ccmys5",
"actorName": "Charlie Brown",
"entityType": "post",
"entityId": "019fffb5-0bb3-757c-a2f9-9168f03bad17",
"webPath": "/posts/019fffb5-…",
"deepLink": "ksn://posts/019fffb5-…"
},
"readAt": null,
"createdAt": "2026-08-21T11:55:29.883Z"
},
"actors": [
{ "id": "cml805qs5…", "username": "charlie", "displayName": "Charlie Brown", "avatarUrl": "https://…" },
{ "id": "cml805qep…", "username": "bob", "displayName": "Bob Smith", "avatarUrl": null }
],
"actorCount": 2
}
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2…",
"unreadCount": 162,
"window": {
"since": "2026-05-23T11:55:31.361Z",
"scanned": 172,
"limit": 1000,
"truncated": false
}
}
}
Field notes
| Field | What it is |
|---|---|
groupKey | Stable identity of the group. Use it as your list key, and to mark the group read. Also arrives on the socket event — see §5. |
entityType / entityId | What the group is about, after per-type resolution. Null for follow-style groups, whose target is you. |
count | Events collapsed here. See §2 for its exact scope. |
unreadCount | Unread events in this group. 0 means the whole group is read. |
partial | true → count/unreadCount may undercount; render "4+" rather than "4". Rare; see §2. |
latest | The newest event in full. Read title, data.deepLink, data.webPath from here. A group of one renders identically to a flat list item — you only need one row renderer. |
actors | Stacked-faces preview, newest-first, at most 3. avatarUrl is nullable — a user may not have set one. |
actorCount | Distinct people in the group. Use actorCount - actors.length for the "+N" overflow, not count. |
Suggested copy: latest.title already reads as a complete sentence for a single
actor. For actorCount > 1, compose from actors[0].displayName and
actorCount - 1 — the server never bakes group phrasing, so you control it.
Which events collapse together
| Type | Collapses on |
|---|---|
like, comment, mention, post_request, event_invite | the entity acted on (entityType:entityId) |
follow, follow_request, follow_accepted | type only — all new followers land in one group |
repost | the original post, not the repost |
message, message_request, message_reply | the conversation |
message_reaction | the message |
post_request_accepted / _rejected / _reopened, post_deleted, message_request_accepted, system | never grouped — each is its own single-item group |
2. What the numbers actually count — read this before showing one
The grouped view reads a window: the last 90 days, capped at 1000 rows.
window on every response tells you what it read.
countandunreadCounton a group are exact withinwindow, and a lower bound outside it.window.truncated: truemeans the 1000-row cap was hit — older events exist that this response did not read. Groups sitting at that boundary carrypartial: true; those are the only ones whose numbers may undercount.- When
window.truncatedisfalse(the normal case), every group's numbers are exact for the window and everypartialisfalse.
data.unreadCount (top level) is different: it is all-time. It is the exact
same number GET /api/notifications/unread-count returns, so the badge and this
screen can never disagree. That means:
sum(groups[].unreadCount) <= data.unreadCount
The gap is unread events older than the window. Don't compute the badge by
summing groups — use data.unreadCount.
3. Paging
Pass nextCursor back as cursor. nextCursor is null/absent on the last
page. The cursor is opaque — don't parse it.
Guarantees while paging:
- A
groupKeyis never returned twice. - A group is never split across two pages — you always get its full
count,actorsandunreadCountin one place.
One caveat, and it is the normal live-feed trade-off:
| What happens mid-scroll | What you see |
|---|---|
| A new event joins a group you already rendered | No duplicate on the next page. Merge it yourself from the socket (§5). |
| A new event joins a group you haven't scrolled to yet | That group moves to the top and is skipped for the rest of this scroll. It's on page 1 after a refresh — or arrives immediately via the socket. |
| An event is deleted or marked read | Counts shift; paging is unaffected. |
Practical rule: treat the socket as the source of new items and the cursor as the source of old ones. Pull-to-refresh restarts from page 1.
4. Read state and the badge
Read state is still per event row, exactly as before. A group is "read" when all its members are.
PATCH /api/notifications/read
{ "groupKey": "like:post:019fffb5-…" }
→ { "success": true, "data": { "count": 2 } } // rows marked read
The server re-derives the group's members from the groupKey, so you never
send an id array. count: 0 means nothing matched — including when the key
isn't yours. That is deliberate (no 403, so keys can't be probed); it is not an
error, don't surface it.
Still available and unchanged:
PATCH /api/notifications/:id/read— one row (uselatest.idfor a single-item group)PATCH /api/notifications/read-all— everythingGET /api/notifications/unread-count→{ data: { count } }— the badgeDELETE /api/notifications/:id— one row
After a bulk read, decrement your badge by the returned count, or re-fetch
unread-count. Both are consistent.
5. Live updates over the socket
Connect as usual (socket.io-client, auth: { token }). You are auto-joined to
your own user room; no join call is needed for notifications.
socket.on("notification", (n) => {
// n is the full notification row PLUS a groupKey:
// { id, type, title, body, data, readAt, createdAt, groupKey }
const existing = groups.find(g => g.groupKey === n.groupKey);
if (existing) {
existing.count += 1;
existing.unreadCount += 1;
existing.latestAt = n.createdAt;
existing.latest = n;
// prepend n.data.actorId to actors if it isn't already there, cap at 3
moveToTop(existing);
} else {
groups.unshift(newGroupFrom(n)); // count 1, actorCount 1
}
badge += 1;
});
groupKey is computed with the exact same rule the list endpoint uses, so a
merged group matches what a refetch would give you. This is also what closes the
paging gap in §3 — a group that jumps to the top arrives here first.
groupKey is a new additive field on this payload; every field that was
there before is unchanged.
6. Preferences (unchanged)
GET /api/notifications/preferences
PATCH /api/notifications/preferences { "preferences": [{ "type": "like", "pushEnabled": false }] }
Preferences gate delivery (in-app / push / email), not grouping. Turning a type off stops new rows being created for it; existing rows and their groups stay.
Related
- push-notifications.md — the delivery architecture and the
datapayload contract - flows/notifications.md — how the backend actually executes this