> ## Documentation Index
> Fetch the complete documentation index at: https://docs.member.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Notifications Inbox

> Per-hub notification inbox: list, read-status, unread count, and inbox-opened tracking.

# Notifications Inbox

The notifications inbox delivers activity alerts to members within a hub. All endpoints are scoped to a single hub and require a valid **contact JWT** (`aud=contact`).

**Resource types:**

| Resource                 | `type` value                 |
| ------------------------ | ---------------------------- |
| Notification             | `notifications`              |
| Unread count             | `notification_unread_counts` |
| Per-category/kind counts | `notification_counts`        |
| Read receipt             | `notification_read_receipts` |
| Last-opened record       | `notification_last_opens`    |

***

## Endpoints

| Method | Path                                                         | What it does                                         |
| ------ | ------------------------------------------------------------ | ---------------------------------------------------- |
| `GET`  | `/api/v1/hubs/{hub_id}/notifications`                        | Paginated list, newest-first                         |
| `GET`  | `/api/v1/hubs/{hub_id}/notifications/unread-count`           | Scalar unread count                                  |
| `GET`  | `/api/v1/hubs/{hub_id}/notifications/counts`                 | Per-category and per-kind breakdown (for inbox tabs) |
| `GET`  | `/api/v1/hubs/{hub_id}/notifications/{notification_id}`      | Single notification                                  |
| `POST` | `/api/v1/hubs/{hub_id}/notifications/{notification_id}/read` | Mark one read (idempotent, 204)                      |
| `POST` | `/api/v1/hubs/{hub_id}/notifications/mark-all-read`          | Bulk mark all read                                   |
| `PUT`  | `/api/v1/hubs/{hub_id}/notifications/last-opened-at`         | Record inbox open time                               |

***

## Authentication

* **Read endpoints** (`GET`) — accept a Bearer token **or** the refresh cookie (`get_current_contact_for_read`).
* **Write endpoints** (`POST`, `PUT`) — require a Bearer token only (`get_current_contact`).
* Hub membership is enforced on every request. A non-member (or banned member) receives `404 hub_not_found` or `403 hub_membership_required`.

***

## List notifications

```
GET /api/v1/hubs/{hub_id}/notifications
Authorization: Bearer <contact_token>
```

Returns notifications for the authenticated contact in this hub, newest-first.

**Query parameters:**

| Parameter          | Type                                                                        | Default | Description                                                                                                                                              |
| ------------------ | --------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `page[size]`       | integer (1–100)                                                             | 20      | Number of results per page                                                                                                                               |
| `page[after]`      | string (UUID)                                                               | —       | Exclusive-start cursor (raw notification id from previous `meta.page.next_cursor`)                                                                       |
| `filter[status]`   | `"unread"` or omit                                                          | —       | Omit for all; `"unread"` for unread-only                                                                                                                 |
| `filter[category]` | `"transactional"` \| `"content"` \| `"community"` \| `"moderation"` or omit | —       | Closed set — narrows the list to one category (e.g. the Moderation tab). Any other value returns **422**                                                 |
| `filter[kind]`     | string (≤ 64 chars) or omit                                                 | —       | Open-ended — narrows the list to one kind (e.g. `mention` for the Mentions tab). An unrecognized kind returns a **200** with an empty page, not an error |

**Response (200):**

```json theme={null}
{
  "data": [
    {
      "type": "notifications",
      "id": "01927abc-...",
      "attributes": {
        "category": "community",
        "kind": "comment_reply",
        "priority": "p1",
        "read": false,
        "created_at": "2026-06-18T14:00:00Z",
        "actor": {
          "contact_id": "01927aaa-...",
          "name": "Jane Doe",
          "avatar_url": "https://cdn.example.com/avatars/jane.jpg"
        },
        "actors": null,
        "count": null,
        "target": {
          "type": "discussions",
          "id": "01927bbb-...",
          "label": "Welcome to the community!"
        },
        "preview": "Great question — here's what I found...",
        "deep_link": "/hubs/my-hub/discussions/01927bbb-...",
        "highlight": null,
        "icon": null
      }
    }
  ],
  "meta": {
    "unread_count": 3,
    "page": {
      "has_more": true,
      "next_cursor": "01927ccc-..."
    }
  }
}
```

**Pagination:** pass `page[after]=<next_cursor>` to fetch the next page. The cursor is the raw notification UUID (not base64-encoded). When `meta.page.has_more` is `false`, you are on the last page.

**Nullable attributes:** `actor`, `actors`, `count`, `target`, `preview`, `deep_link`, `highlight`, and `icon` are always present in the response body (serialized as `null` when absent — never omitted). Clients must handle `null`.

**Filtering a tab's content:** `filter[category]` and `filter[kind]` narrow the list server-side so a tab's content and its pagination are always complete — do not client-filter an already-fetched page. Both compose (AND) with each other, with `filter[status]=unread`, and with `page[after]`. Because the list and the `/counts` endpoint (below) share the same visibility rules, a filtered list's total row count across all pages always matches that filter's `/counts` bucket (e.g. `filter[kind]=mention` sums to `kinds.mention.total`). `meta.unread_count` is never affected by these filters — it always reflects the global bell-visible unread total, matching `/unread-count`; use `/counts` for a per-tab unread number. `filter[kind]=direct-message` always returns an empty page — direct messages are conversation-owned, not bell content.

```
GET /api/v1/hubs/{hub_id}/notifications?filter[kind]=mention
GET /api/v1/hubs/{hub_id}/notifications?filter[category]=moderation
```

***

## Notification attributes

| Field        | Type                     | Description                                                  |
| ------------ | ------------------------ | ------------------------------------------------------------ |
| `category`   | string                   | Broad category — e.g. `"community"`, `"content"`, `"system"` |
| `kind`       | string                   | Specific event kind — e.g. `"comment_reply"`, `"new_post"`   |
| `priority`   | `"p0"` / `"p1"` / `"p2"` | Display priority. `p0` = urgent; `p2` = low                  |
| `read`       | boolean                  | Whether the contact has read this notification               |
| `created_at` | ISO 8601 datetime        | When the notification was created                            |
| `actor`      | object or null           | The single contact who triggered the event                   |
| `actors`     | array or null            | Multiple contacts (e.g. "Jane and 3 others liked your post") |
| `count`      | integer or null          | Supporting count for grouped notifications                   |
| `target`     | object or null           | The resource the notification refers to                      |
| `preview`    | string or null           | Short text preview (e.g. first line of a comment)            |
| `deep_link`  | string or null           | In-hub URL to navigate directly to the relevant content      |
| `highlight`  | boolean or null          | Whether to visually highlight the notification in the inbox  |
| `icon`       | string or null           | Icon identifier for custom rendering                         |

**`actor` shape:**

```json theme={null}
{
  "contact_id": "<uuid>",
  "name": "Jane Doe",
  "avatar_url": "https://..." 
}
```

**`target` shape:**

```json theme={null}
{
  "type": "discussions",
  "id": "<uuid>",
  "label": "Welcome to the community!"
}
```

***

## Get unread count

```
GET /api/v1/hubs/{hub_id}/notifications/unread-count
Authorization: Bearer <contact_token>
```

Lightweight endpoint for badge display. Use this instead of fetching the full list just to get the count.

**Response (200):**

```json theme={null}
{
  "data": {
    "type": "notification_unread_counts",
    "id": "<hub_id>",
    "attributes": {
      "total": 3
    }
  }
}
```

***

## Get per-category/kind counts

```
GET /api/v1/hubs/{hub_id}/notifications/counts
Authorization: Bearer <contact_token>
```

Server-aggregated breakdown for building inbox tabs (e.g. "Unread", "Mentions", "Moderation") without fetching the full list. Uses the exact same visibility rules as the list and unread-count endpoints — direct-message notifications and dismissed notifications never contribute to any number here, so `attributes.unread` always matches the `unread-count` endpoint's `total` for the same contact.

* `categories` is a fixed set of four keys (`transactional`, `content`, `community`, `moderation`) — always present, even at zero.
* `kinds` is sparse — only kinds with at least one matching notification appear (e.g. `mention`, `comment`, `new-report`).
* Each bucket is `{ "total": <int>, "unread": <int> }`, where `total` counts read + unread and `unread` counts unread only.

**Response (200):**

```json theme={null}
{
  "data": {
    "type": "notification_counts",
    "id": "<hub_id>",
    "attributes": {
      "unread": 3,
      "categories": {
        "transactional": { "total": 0, "unread": 0 },
        "content": { "total": 0, "unread": 0 },
        "community": { "total": 3, "unread": 2 },
        "moderation": { "total": 1, "unread": 1 }
      },
      "kinds": {
        "mention": { "total": 2, "unread": 2 },
        "new-report": { "total": 1, "unread": 1 }
      }
    }
  }
}
```

A typical inbox composes tabs from this response: **Unread** uses `attributes.unread`; **Mentions** uses `attributes.kinds.mention.total`; **Moderation** uses `attributes.categories.moderation.total`. Any future tab can read the same generic map without a backend change.

***

## Get single notification

```
GET /api/v1/hubs/{hub_id}/notifications/{notification_id}
Authorization: Bearer <contact_token>
```

Fetch one notification by its id. Returns `404 notification_not_found` if the notification does not belong to the authenticated contact.

**Response (200):** same shape as a single item in the list `data` array, wrapped in `{ "data": { ... } }`.

***

## Mark one notification read

```
POST /api/v1/hubs/{hub_id}/notifications/{notification_id}/read
Authorization: Bearer <contact_token>
```

Idempotent. Calling it on an already-read notification succeeds silently.

**Response:** `204 No Content` (no body).

**Errors:**

* `404 notification_not_found` — notification does not belong to this contact.
* `403 hub_membership_required` — contact is banned from this hub.

***

## Mark all notifications read

```
POST /api/v1/hubs/{hub_id}/notifications/mark-all-read
Authorization: Bearer <contact_token>
```

Bulk marks every unread notification for this contact + hub as read. Returns the timestamp and the count of rows updated.

**Response (200):**

```json theme={null}
{
  "data": {
    "type": "notification_read_receipts",
    "id": "<hub_id>",
    "attributes": {
      "read_at": "2026-06-18T14:30:00Z",
      "marked_count": 3
    }
  }
}
```

`marked_count` will be `0` if there were no unread notifications — this is not an error.

***

## Record inbox last-opened time

```
PUT /api/v1/hubs/{hub_id}/notifications/last-opened-at
Authorization: Bearer <contact_token>
```

Upserts `last_opened_at = now()` for this `(hub_id, contact_id)` pair. Use this whenever the member opens the inbox UI so the backend can determine which notifications arrived since the last visit (e.g. for a "new since you last looked" divider in the UI).

**Response (200):**

```json theme={null}
{
  "data": {
    "type": "notification_last_opens",
    "id": "<hub_id>",
    "attributes": {
      "last_opened_at": "2026-06-18T14:31:00Z"
    }
  }
}
```

***

## Typical inbox polling pattern

```
1. GET /notifications/unread-count
   → display badge with `attributes.total`

2. User opens inbox →
   PUT /notifications/last-opened-at  (record open time)
   GET /notifications?page[size]=20   (first page)

3. User scrolls →
   GET /notifications?page[after]=<next_cursor>&page[size]=20

4. User taps a notification →
   POST /notifications/{id}/read

5. User taps "Mark all read" →
   POST /notifications/mark-all-read
```

***

## Error codes

| Code                      | Status | Meaning                                                       |
| ------------------------- | ------ | ------------------------------------------------------------- |
| `hub_not_found`           | 404    | Hub does not exist or contact is not a member                 |
| `hub_membership_required` | 403    | Contact is banned from this hub                               |
| `notification_not_found`  | 404    | Notification does not exist or belongs to a different contact |

***

## Web Push (browser notifications)

Web Push enables the browser to receive notifications even when the member does not have the hub tab open. The backend uses the VAPID protocol (MIO-1145).

**Resource types:**

| Resource          | `type` value             |
| ----------------- | ------------------------ |
| VAPID public key  | `vapid_keys`             |
| Push subscription | `web_push_subscriptions` |

### Endpoints

| Method   | Path                                                             | What it does                        |
| -------- | ---------------------------------------------------------------- | ----------------------------------- |
| `GET`    | `/api/v1/hubs/{hub_id}/web-push/vapid-public-key`                | Retrieve the hub's VAPID public key |
| `POST`   | `/api/v1/hubs/{hub_id}/web-push/subscriptions`                   | Register a push subscription (201)  |
| `DELETE` | `/api/v1/hubs/{hub_id}/web-push/subscriptions/{subscription_id}` | Unregister a subscription (204)     |

### Three-step setup

**Step 1 — Get the VAPID public key**

```
GET /api/v1/hubs/{hub_id}/web-push/vapid-public-key
Authorization: Bearer <contact-token>
```

Response (200):

```json theme={null}
{
  "data": {
    "type": "vapid_keys",
    "id": "<hub_id>",
    "attributes": {
      "public_key": "BNm...base64url-encoded-key..."
    }
  }
}
```

Pass `attributes.public_key` to the browser's `PushManager.subscribe()` call as the `applicationServerKey`.

**Step 2 — Subscribe in the browser**

```js theme={null}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
```

**Step 3 — POST the subscription to the backend**

```
POST /api/v1/hubs/{hub_id}/web-push/subscriptions
Authorization: Bearer <contact-token>
Content-Type: application/vnd.api+json
```

Request body:

```json theme={null}
{
  "data": {
    "type": "web_push_subscriptions",
    "attributes": {
      "endpoint": "https://fcm.googleapis.com/fcm/send/...",
      "p256dh": "<base64url-encoded p256dh key>",
      "auth": "<base64url-encoded auth secret>"
    }
  }
}
```

Response: `201 Created` with the created subscription resource.

### Removing a subscription

```
DELETE /api/v1/hubs/{hub_id}/web-push/subscriptions/{subscription_id}
Authorization: Bearer <contact-token>
```

Response: `204 No Content`. A `404` is expected and safe to ignore if the subscription was already deleted.

### Notification preferences

`notification_prefs.push` is now a valid key on the notification-preferences PATCH. Valid preference keys:

| Key            | Controls                  |
| -------------- | ------------------------- |
| `email`        | Email notifications       |
| `daily_digest` | Daily digest email        |
| `mobile`       | Mobile push notifications |
| `push`         | Web push notifications    |

Set `push: false` to suppress web push delivery without deleting the subscription.

### Error codes

| Code                      | Status | Meaning                                                             |
| ------------------------- | ------ | ------------------------------------------------------------------- |
| `missing_device`          | 422    | Request is missing device identification                            |
| `missing_endpoint`        | 422    | No `endpoint` provided in the subscription body                     |
| `invalid_subscription`    | 422    | The subscription object is malformed or the endpoint is unreachable |
| `hub_not_found`           | 404    | Hub does not exist                                                  |
| `hub_membership_required` | 403    | Contact is not an active hub member                                 |

**Rate limit:** 60 subscribe requests per hour per contact per hub. Exceeding the limit returns `429`.

### Real-time badge sync via SSE

When a new notification arrives, the server emits a `community.notification.created` event on the SSE stream. Listen for this event to update the unread badge without polling:

```js theme={null}
eventSource.addEventListener('community.notification.created', (e) => {
  const { notification_id, hub_id } = JSON.parse(e.data);
  // Increment the badge or re-fetch unread-count
  refreshUnreadBadge(hub_id);
});
```

See the [Realtime SSE guide](/guides/realtime-sse) for connection setup and the full event-type reference.
