Mobile Notifications — Integration Guide
For mobile development. Everything needed to render the Activity tab — 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.
One endpoint populates the tab:
GET /api/notifications/grouped. The flatGET /api/notificationsis not needed and you should not call it — see §7.
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>on every request. - Send
X-Client-Type: mobileon every request.
What's new
- 2026-10-07 — No notifications between two people when either has blocked the other, of any type, push included. That covers group-message pushes too; the messages still show in the chat. Blocking also clears every existing notification between the pair. Nothing to build.
- 2026-10-07 — Every user object here now also carries
gradeandschool(bothstring | null, from the user's current school) next toisStudentVerified— show them under the name.null= no current school on file. Only sent to student-verified viewers: an unverified viewer gets neither key on anyone but themselves, so treat missing as "hidden". Additive for verified users. Details: Student verification guide. - 2026-10-06 — New: a "You're verified!" notification when student verification is approved — push + Activity row, type
student_verification,data.decision: "APPROVED". Fires on human review and on the school-emailPOST /api/student-verification/completeauto-approve; not at signup. It has nodeepLink— this guide previously said every notification has one, which was wrong for this type; on tap, refetchGET /api/student-verification/status(§5.2). - 2026-10-05 — Every user object in notification
actors[]now carriesisStudentVerified: boolean— show the verified badge whentrue. Additive: nothing breaks if you ignore it. Details: Student verification guide. - 2026-10-01 —
data.thumbnailUrladded. Any notification about a post or event that has an image now carriesdata.thumbnailUrl(the post/event's first image, thumbnail variant) — on the grouped list'slatest.data(§1) and on the push payload (§5.1). Absent, not null, when there's no image or the type isn't post/event-shaped (follow, chat,comment_removed). No client action needed if you ignore it; render it as the row's leading thumbnail if you want one.
Short version — just the calls
0. POST /api/devices → register the push token (see §0 — required for push)
1. GET /api/notifications/grouped → the Activity tab. This is the whole screen.
2. GET /api/notifications/unread-count→ the badge, from anywhere else in the app
3. PATCH /api/notifications/read → mark one whole group read { groupKey }
4. PATCH /api/notifications/read-all → mark everything read
5. socket "notification" → a new event arrived (carries groupKey)
PATCH /api/notifications/:id/read → one row. Only for a single-item group (latest.id)
DELETE /api/notifications/:id → one row. See §7 before offering delete
GET /api/notifications → flat list. NOT NEEDED — do not call it (§7)
The tab is (1) and nothing else. One call renders it: /grouped returns the
collapsed groups, the newest event of each in full, the actor avatars already
joined, per-group counts, and the all-time unreadCount for the badge — so on
that screen you don't even need (2). A group of one renders exactly like a flat
row, so you write one row renderer, not two.
(0) is not optional. Without it the server has no token to push to and the lock screen stays silent — that is the state today, for every user.
This is the Activity tab. Chat lives somewhere else and always has:
Messages / Inbox → GET /api/chat/conversations?folder=inbox
Messages / Requests → GET /api/chat/conversations?folder=requests
Those already carry per-conversation unreadCount. Chat events are not in
/api/notifications — not in the list, not in the grouped list, not in
unread-count. There is nothing to filter out; the rows do not exist. Two
badges, two owners:
| Badge | Source |
|---|---|
| Activity | GET /api/notifications/unread-count |
| Messages / Requests | the unreadCount on each conversation |
0. Register the device for push
Do this before anything else on this page. The server cannot discover your push token: it is minted on the device by the FCM/APNs SDK, it is not in any header, and it rotates on its own schedule. You have to hand it over.
getToken()returns nothing until Firebase is set up on each platform — app registration,google-services.json/GoogleService-Info.plist, the APNs auth key iOS is blocked without, Android 13'sPOST_NOTIFICATIONS, and the notification channel Android needs to render a banner at all. That is a page of its own: Push setup — FCM on iOS and Android. This section is only the API call at the end of it.
POST /api/devices
Authorization: Bearer <accessToken>
{
"token": "<FCM registration token>", // required, ≤4096 chars
"platform": "IOS", // required: IOS | ANDROID | WEB
"appVersion": "1.0.3", // optional, ≤50
"locale": "en-US", // optional, ≤20
"timezone": "Asia/Kolkata" // optional, ≤50
}
Returns { success: true, data: { id, userId, token, platform, … } }. Keep
the id — it is what you delete on logout.
When to call it
| When | Why |
|---|---|
| After login, once you hold an access token | The route is authenticated. A token registered before login has no user to attach to. |
| After the OS notification permission is granted | iOS will not issue an APNs token before the prompt is accepted; Android 13+ needs POST_NOTIFICATIONS too. Calling earlier gets you nothing to send. |
| On every app launch (foreground) | Cheap and idempotent — it refreshes lastSeenAt and any metadata you pass. This is how a device stays "active". |
| On every token refresh | ← the one that gets missed. onTokenRefresh / onNewToken fires on reinstall, restore-from-backup, app-data clear, and FCM's own rotation. Miss it and push dies silently for that user, with nothing in your logs. |
On logout → DELETE /api/devices/:id | Stops push to that device. The user's other devices keep receiving. |
Idempotent on the token: re-registering the same one updates the row rather
than creating a second. So "call it on launch and on refresh" is safe to do
unconditionally — no need to track whether you registered already.
import messaging from "@react-native-firebase/messaging";
async function registerDevice() {
const granted = await messaging().requestPermission();
if (!granted) return; // nothing to register
const token = await messaging().getToken();
await api.post("/api/devices", {
token,
platform: Platform.OS === "ios" ? "IOS" : "ANDROID",
appVersion: DeviceInfo.getVersion(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
}
// after login and on every launch
registerDevice();
// and the one people forget
messaging().onTokenRefresh(registerDevice);
Things you do not have to handle
- Dead tokens. When the provider reports a token as gone (uninstall, expiry),
the server disables it on the next send. A missed
DELETEon logout is untidy, not broken. - Account switching on one device. Registering the same token while logged in as someone else moves it to the new user — the login-time call covers it.
- Duplicate registrations. See idempotency above.
Related
GET /api/devices lists the current user's devices, if you want a "where am I
logged in?" screen. PATCH /api/devices/:id updates appVersion / locale /
timezone without re-sending the token.
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:20719",
"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": "clustr://posts/019fffb5-…",
"thumbnailUrl": "https://…/thumb.webp"
},
"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. Ends in a UTC-day number — opaque, never parse it. 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. |
latest.data.thumbnailUrl | The post/event's first image, thumbnail variant. Absent, not null, when the target has no image or isn't post/event-shaped (follow, chat, comment_removed) — check for presence, don't rely on null. |
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, mention, post_request, event_invite | the entity acted on (entityType:entityId) |
comment | the post — so several comments on one post form one group |
comment_reply | the parent comment |
follow, follow_request, follow_accepted | type only — all new followers land in one group |
repost | the original post, not the repost |
post_request_accepted / _rejected / _reopened, post_deleted, post_removed, comment_removed, system | never grouped — each is its own single-item group |
Plus a day bucket. Two events collapse only when they share the rule above
and fall on the same UTC day. A like from last week never joins today's
stack. That is why groupKey ends in a number — it is the day. Never parse a
groupKey; pass it back as you received it.
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.
Paging ends at the window, not at the account's history. Every request reads the same 90-day / 1000-row window (§2) and the cursor walks the groups inside it, so infinite scroll terminates there rather than continuing into older activity. That is the intended Activity tab; §7 says what to do if a history screen is ever wanted.
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-…:20719" }
→ { "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.
5.1 The push payload
What actually arrives over FCM, for both Activity and chat events. The tray shows
notification.title / notification.body; everything you route on is in data,
and FCM stringifies every data value — numbers and objects arrive as
strings.
{
"notification": {
"title": "Alice commented: \"see you there\"", // what the tray shows
"body": "see you there" // often absent
},
"data": {
"type": "comment", // ← the notification type. Route on this.
"entityType": "comment",
"entityId": "01a0…",
"actorId": "cml8…",
"actorName": "Alice",
"deepLink": "clustr://posts/019f…?commentId=01a0…",
"webPath": "/posts/019f…#comment-01a0…",
"postId": "019f…", // type-specific extras, see §5.2
"thumbnailUrl": "https://…/thumb.webp", // only when the post/event has an image — see §1
"notificationId": "01a0…", // the row id (a throwaway id for chat)
"platform": "IOS"
}
}
Notes that matter:
typeis on the push payload only — it is a column on the row, not a field insidedata, so the socket/REST shape puts it at the top level (n.type) and the push puts it indata.type. Same value.- The push title is not always the in-app title. Where the spec asks for
different wording, push gets the short form and the list gets the quoted one —
a comment like pushes "Alice liked your comment." and lists as
Alice liked your comment: "…". Render lists from the API, trays from the push. - Chat previews live here and nowhere else. A DM push reads
Alice: are we still meeting; no row is written, so that text is never stored. - Collapse. Repeat events on one thing replace each other in the tray rather
than stacking (Android
collapseKey+tag, iOSapns-collapse-id+threadId). Inside an hour the title aggregates — "Alice and 4 others liked your post." — and the body is dropped, because it spoke for one event. Chat collapses per conversation: one thread, one tray entry, newest message. - Badge. iOS gets
aps.badgeset for you — Activity unread plus chat unread. Don't also increment it from the payload on iOS or it double-counts. Android gets no badge; do it yourself fromunread-countif you show one. - A pending DM request pushes once. Later messages into a thread the user hasn't accepted are silent; they still land in the requests tray.
5.2 Deep links — the full list
data.deepLink is the right destination whenever present — every type has one
except student_verification. Register these routes:
type | deepLink | Lands on |
|---|---|---|
follow, follow_request, follow_accepted | clustr://users/<username> | Actor's profile. Note it is the username, while data.entityId is the user id — route on the link, not the id |
like (post), repost, mention (post) | clustr://posts/<postId> | The post |
like (comment), comment, comment_reply, mention (comment) | clustr://posts/<postId>?commentId=<id> | The post, scrolled to the comment |
post_request | clustr://posts/<postId>/requests | Your request inbox for that post |
post_request_accepted | clustr://chat/<conversationId> or clustr://posts/<postId> | The DM the accept opened, when there is one; otherwise the post |
post_request_rejected, post_request_reopened | clustr://posts/<postId> | The post |
post_deleted | clustr://requests | Your outgoing requests — the post is gone, so a post route would 404 |
post_removed, comment_removed | clustr://moderation/<targetId> | Your own moderation-detail sheet, rendered from data (reason, contentDescription, appealEmail). There is no endpoint to call |
event_invite | clustr://events/<eventId> | The event |
message, message_reply, message_reaction, chat_mention | clustr://chat/<conversationId>?messageId=<id> | The thread, at that message |
message_request | clustr://chat/requests/<conversationId> | The requests tray |
message_request_accepted | clustr://chat/<conversationId> | The thread |
student_verification | — (none) | No link. data is { status, decision } — decision is APPROVED ("You're verified!"), REJECTED or REVOKED. On tap, refetch GET /api/student-verification/status and route on stage |
webPath is the same destination for web and is not used by mobile.
6. Preferences
GET /api/notifications/preferences
PATCH /api/notifications/preferences { "preferences": [{ "type": "like", "pushEnabled": false }] }
GET returns one entry per type — { type, pushEnabled, inAppEnabled, emailEnabled, isDefault }
— with defaults filled in, so it is also the list to build the settings screen
from. isDefault: true means no row is stored for that type yet.
What each toggle actually does (this guide previously got it wrong):
| Toggle | Effect |
|---|---|
pushEnabled: false | No push for that type. The only toggle that stops a delivery outright. |
inAppEnabled: false | Stops the live socket event only. The row is still written, still appears in /grouped, and still counts toward unread-count. The user sees it on next load, just not live. |
emailEnabled | Stored, read by nothing today. No notification type sends email. |
So there is no setting that makes an Activity event disappear from the list. Turning a type off does not remove existing rows or their groups either.
PATCH only what the user actually toggled. A stored row beats the server default permanently, for that type, forever. If you send the whole
GETresponse back, you freeze today's defaults into the database and that user never receives a future default change — we hit exactly this withlike, where turning push on for everyone silently skipped every account that had a full payload written. One toggle → one entry in the array.
The types
Activity (these are rows, and they group):
type | Meaning |
|---|---|
follow, follow_request, follow_accepted | Follow graph |
like | A post or a comment — read data.entityType to tell which |
comment, comment_reply | Comment on your post / reply to your comment |
mention | In a post or a comment — again data.entityType |
repost | Someone reposted your post |
post_request | Someone asked to join your post |
post_request_accepted, post_request_rejected, post_request_reopened | Your request was answered |
post_deleted | A post you had asked to join is gone |
post_removed, comment_removed | Moderation. data carries reason, contentDescription, appealEmail — render the sheet from it, there is no endpoint |
event_invite | Someone is attending your event |
system | Reserved. Nothing sends it today |
Messages (push only — no row, nothing in /api/notifications):
message, message_request, message_request_accepted, message_reply,
message_reaction, chat_mention. Defaults are push-on for all of them except
message_reaction, which is off — a buzz per emoji tap is noise.
7. You do not need the flat list
GET /api/notifications returns the same events one row each, and the app has
no screen that wants it. The product has no "see all activity history" view, so
/grouped is the entire Activity surface. Treat the flat route as legacy: it stays
for web and for anything that might later want an unbounded history feed.
Concretely, calling it buys you nothing and costs you something:
- Every row it returns is already reachable — as a group's
latest, or as a single-item group. - Its cursor is a bare row
id, not the grouped route's opaque cursor. The two are not interchangeable in either direction, so mixing them is a bug waiting in your pagination state. - It returns raw DB rows, not the serialized DTO, so it has no
actors, nogroupKey, no counts — you would have to group and hydrate avatars yourself.
What /grouped deliberately does not cover
Three things have no endpoint. None of them is on the Activity tab as designed — listed so you don't discover it mid-sprint:
| Want | Status |
|---|---|
| Activity older than the window | /grouped reads the last 90 days, capped at 1000 rows, and paging does not reach past that — nextCursor walks groups inside the window, then ends. An account with more than 1000 events in 90 days cannot see the older ones here. window.truncated: true tells you it hit the cap. Needs the flat list, and a history screen to put it on. |
| Expanding a group ("show me all 20 likes") | Not available. You get latest in full plus at most 3 actors and actorCount. The other members are not in the response and the flat route can't filter by group. |
| Deleting a group | Not available. DELETE /:id removes one row — on a 20-event group it deletes the newest and leaves 19 behind, which reads as "nothing happened". So either don't offer swipe-to-delete on a multi-event group, or offer "mark read" instead (PATCH /read does take a groupKey). |
Ask if any of these becomes a real screen — a ?groupKey= filter and a group
delete are both small additions on our side.
Today / This week / Earlier
There is no section field. The screen's time buckets are yours to compute from
latestAt, in the device's timezone — the grouping day bucket is UTC, so a
group's key and the section you file it under can disagree near midnight. That is
fine; they answer different questions. Same for "2h" / "3d" / "2w" labels: format
latestAt locally.
Related
- push-notifications.md — the delivery architecture and the
datapayload contract - Push setup — FCM on iOS and Android — Firebase project, platform config, APNs key, the four app states
- flows/notifications.md — how the backend actually executes this