# 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

```http
GET /api/notifications/grouped?limit=20&cursor=<opaque>
```

`limit` is clamped server-side to 1–50 (default 20).

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

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

```http
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.

```js
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)

```http
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](../../push-notifications.md) — the delivery architecture and the `data` payload contract
- [flows/notifications.md](../../flows/notifications.md) — how the backend actually executes this
