Skip to main content
Version: Latest

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 flat GET /api/notifications is 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: mobile on 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 grade and school (both string | null, from the user's current school) next to isStudentVerified — 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-email POST /api/student-verification/complete auto-approve; not at signup. It has no deepLink — this guide previously said every notification has one, which was wrong for this type; on tap, refetch GET /api/student-verification/status (§5.2).
  • 2026-10-05 — Every user object in notification actors[] now carries isStudentVerified: boolean — show the verified badge when true. Additive: nothing breaks if you ignore it. Details: Student verification guide.
  • 2026-10-01 — data.thumbnailUrl added. Any notification about a post or event that has an image now carries data.thumbnailUrl (the post/event's first image, thumbnail variant) — on the grouped list's latest.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:

BadgeSource
ActivityGET /api/notifications/unread-count
Messages / Requeststhe 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's POST_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​

WhenWhy
After login, once you hold an access tokenThe route is authenticated. A token registered before login has no user to attach to.
After the OS notification permission is grantediOS 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/:idStops 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 DELETE on 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.

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​

FieldWhat it is
groupKeyStable 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 / 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.
partialtrue → count/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.
latest.data.thumbnailUrlThe 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.
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, mention, post_request, event_invitethe entity acted on (entityType:entityId)
commentthe post — so several comments on one post form one group
comment_replythe parent comment
follow, follow_request, follow_acceptedtype only — all new followers land in one group
repostthe original post, not the repost
post_request_accepted / _rejected / _reopened, post_deleted, post_removed, comment_removed, systemnever 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.

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

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-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-…: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 (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.


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:

  • type is on the push payload only — it is a column on the row, not a field inside data, so the socket/REST shape puts it at the top level (n.type) and the push puts it in data.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, iOS apns-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.badge set 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 from unread-count if 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.

data.deepLink is the right destination whenever present — every type has one except student_verification. Register these routes:

typedeepLinkLands on
follow, follow_request, follow_acceptedclustr://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_requestclustr://posts/<postId>/requestsYour request inbox for that post
post_request_acceptedclustr://chat/<conversationId> or clustr://posts/<postId>The DM the accept opened, when there is one; otherwise the post
post_request_rejected, post_request_reopenedclustr://posts/<postId>The post
post_deletedclustr://requestsYour outgoing requests — the post is gone, so a post route would 404
post_removed, comment_removedclustr://moderation/<targetId>Your own moderation-detail sheet, rendered from data (reason, contentDescription, appealEmail). There is no endpoint to call
event_inviteclustr://events/<eventId>The event
message, message_reply, message_reaction, chat_mentionclustr://chat/<conversationId>?messageId=<id>The thread, at that message
message_requestclustr://chat/requests/<conversationId>The requests tray
message_request_acceptedclustr://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):

ToggleEffect
pushEnabled: falseNo push for that type. The only toggle that stops a delivery outright.
inAppEnabled: falseStops 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.
emailEnabledStored, 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 GET response back, you freeze today's defaults into the database and that user never receives a future default change — we hit exactly this with like, 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):

typeMeaning
follow, follow_request, follow_acceptedFollow graph
likeA post or a comment — read data.entityType to tell which
comment, comment_replyComment on your post / reply to your comment
mentionIn a post or a comment — again data.entityType
repostSomeone reposted your post
post_requestSomeone asked to join your post
post_request_accepted, post_request_rejected, post_request_reopenedYour request was answered
post_deletedA post you had asked to join is gone
post_removed, comment_removedModeration. data carries reason, contentDescription, appealEmail — render the sheet from it, there is no endpoint
event_inviteSomeone is attending your event
systemReserved. 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, no groupKey, 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:

WantStatus
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 groupNot 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.


⤓ Download .md