Skip to main content
Version: Latest

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: mobile on 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

FieldWhat it is
groupKeyStable 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 / entityIdWhat the group is about, after per-type resolution. Null for follow-style groups, whose target is you.
countEvents collapsed here. See §2 for its exact scope.
unreadCountUnread events in this group. 0 means the whole group is read.
partialtruecount/unreadCount may undercount; render "4+" rather than "4". Rare; see §2.
latestThe 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.
actorsStacked-faces preview, newest-first, at most 3. avatarUrl is nullable — a user may not have set one.
actorCountDistinct 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

TypeCollapses on
like, comment, mention, post_request, event_invitethe entity acted on (entityType:entityId)
follow, follow_request, follow_acceptedtype only — all new followers land in one group
repostthe original post, not the repost
message, message_request, message_replythe conversation
message_reactionthe message
post_request_accepted / _rejected / _reopened, post_deleted, message_request_accepted, systemnever 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.

  • count and unreadCount on a group are exact within window, and a lower bound outside it.
  • window.truncated: true means the 1000-row cap was hit — older events exist that this response did not read. Groups sitting at that boundary carry partial: true; those are the only ones whose numbers may undercount.
  • When window.truncated is false (the normal case), every group's numbers are exact for the window and every partial is false.

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 groupKey is never returned twice.
  • A group is never split across two pages — you always get its full count, actors and unreadCount in one place.

One caveat, and it is the normal live-feed trade-off:

What happens mid-scrollWhat you see
A new event joins a group you already renderedNo duplicate on the next page. Merge it yourself from the socket (§5).
A new event joins a group you haven't scrolled to yetThat 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 readCounts 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 (use latest.id for a single-item group)
  • PATCH /api/notifications/read-all — everything
  • GET /api/notifications/unread-count{ data: { count } } — the badge
  • DELETE /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.


⤓ Download .md