# Notification Inbox Protocol (Frontend <-> Backend) This document defines the notification inbox contract for authenticated users. Protocol verification status: - Backend route source: `backend/src/v1/notifications/router.py` - Backend service source: `backend/src/v1/notifications/service.py` - Backend repository source: `backend/src/v1/notifications/repository.py` - Backend schema source: `backend/src/v1/notifications/schemas.py` ## Compatibility strategy - Additive evolution only. - Existing response fields are stable and must remain backward-compatible. - New `action` values may be added to `payload`; unknown `action` values must be ignored by the client. ## Routes ### GET /api/v1/notifications List notifications for the current user. **Authorization**: Requires authenticated session. User identity from JWT `sub`. **Query parameters**: - `limit` (optional, integer, default 20, max 50): number of items per page - `cursor` (optional, string): pagination cursor (ISO 8601 timestamp of last item's `created_at`) **Response (200)**: ```json { "items": [ { "id": "uuid", "notificationId": "uuid", "type": "system", "title": "Welcome", "body": "Welcome to the app!", "payload": { "action": "none" }, "isRead": false, "readAt": null, "createdAt": "2026-04-10T00:00:00Z" } ], "nextCursor": "2026-04-09T12:00:00Z", "hasMore": true } ``` Field rules: - `items`: array of notification items, ordered by `createdAt` descending - `nextCursor`: timestamp cursor for next page, `null` if no more items - `hasMore`: boolean indicating if more items exist - `type`: string, currently only `system` - `payload`: discriminated union (see Payload section below) - `isRead`: boolean - `readAt`: ISO 8601 timestamp or `null` - Results are filtered: `notifications.status = 'published'` and `notifications.deleted_at IS NULL` ### GET /api/v1/notifications/unread-count Get the number of unread notifications for the current user. **Authorization**: Requires authenticated session. User identity from JWT `sub`. **Response (200)**: ```json { "count": 5 } ``` Field rules: - `count`: integer `>= 0` - Counts only notifications where `notifications.status = 'published'` and `notifications.deleted_at IS NULL` ### PATCH /api/v1/notifications/{id}/read Mark a single notification as read. Idempotent. **Authorization**: Requires authenticated session. `id` must belong to the current user's `user_notifications`. **Path parameters**: - `id`: UUID of the `user_notifications` record **Response (200)**: ```json { "id": "uuid", "notificationId": "uuid", "type": "system", "title": "Welcome", "body": "Welcome to the app!", "payload": { "action": "none" }, "isRead": true, "readAt": "2026-04-10T01:00:00Z", "createdAt": "2026-04-10T00:00:00Z" } ``` **Error responses**: - 404 `NOTIFICATION_NOT_FOUND`: notification not found or not owned by current user - Already-read notifications return 200 with current state (idempotent) ### PATCH /api/v1/notifications/mark-all-read Mark all unread notifications for the current user as read. Idempotent. **Authorization**: Requires authenticated session. User identity from JWT `sub`. **Response (200)**: ```json { "updatedCount": 3 } ``` Field rules: - `updatedCount`: integer `>= 0`, number of notifications that were actually changed from unread to read - If all notifications are already read, returns `{ "updatedCount": 0 }` - Only affects notifications where `notifications.status = 'published'` and `notifications.deleted_at IS NULL` ## Payload `payload` is a discriminated union based on the `action` field. ### action = "none" No navigation action on tap. ```json { "action": "none" } ``` ### action = "open_route" Navigate to an in-app route. ```json { "action": "open_route", "route": "/divination/history", "entityId": "optional-uuid", "tab": "optional-tab-name" } ``` Field rules: - `route`: required, string, max 200 characters, app-internal route path - `entityId`: optional, string, max 64 characters, business object ID - `tab`: optional, string, max 32 characters, sub-page navigation parameter - `url`: must be absent ### action = "open_url" Open an external URL. ```json { "action": "open_url", "url": "https://example.com/page" } ``` Field rules: - `url`: required, string, max 500 characters, external URL - `route`, `entityId`, `tab`: must be absent ## Error contract linkage - RFC7807 + extension `code`, optional `params`. - Shared registry: `docs/protocols/common/http-error-codes.md`. - New error codes for this feature are registered in the same registry.