An official website of the United States GovernmentUnclassified
Seal of the Department of the NavyDepartment of the NavyProject ROUNDTABLE Docs
API reference

Notifications API

In-app notification lists, preferences, reads, and rerouting

Notifications are database rows displayed by the bell UI. They are not external email. Every read/list operation scopes by the current user's ID, while rerouting is a government operation that recalculates submitted records.

GET /api/notifications

Auth: Any signed-in user. Success: 200.

Query fieldTypeRequired?Constraints/default
pageintegerNoMinimum 1; default 1.
pageSizeintegerNo1–50; default 20.
unreadenumNo"true" or "false".
curl 'http://localhost:3000/api/notifications?page=1&pageSize=20&unread=true' \
  -H 'cookie: next-auth.session-token=SESSION'

Example:

{"data":{"items":[{"id":"clx-notification","kind":"SUBMISSION_MATCH","title":"New submission routed: Autonomous UUV navigation","body":"Pelagic Dynamics submitted...","linkUrl":"/gov/organizations/clx-pelagic?submission=clx-submission","read":false,"createdAt":"2026-02-01T12:00:01.000Z"}],"total":1,"page":1,"pageSize":20}}

Errors: 401, 400 invalid query, 500. Pagination is applied after the current user's filter.

notifyGovByTags builds submission-match links as /gov/organizations/<orgId>?submission=<submissionId> (src/lib/matching.ts), and the organization page reads that parameter: it keeps the value only when the ID belongs to one of that organization's submissions, then highlights the row, offsets the scroll target by the sticky header height, and gives the row tabIndex={-1} so SubmissionDeepLink can move focus to it after paint. Motion is suppressed under prefers-reduced-motion.

A parameter that does not match one of the organization's submissions is discarded silently and the page renders normally — no highlight, no error. Any new deep-linked notification kind must add the same target-side handling; a query parameter no page reads is a broken link that looks fine in the API response.

GET /api/notifications/unread-count

Auth: Any signed-in user. Success: 200.

curl http://localhost:3000/api/notifications/unread-count \
  -H 'cookie: next-auth.session-token=SESSION'

Response: { "data": { "count": 3 } }. Errors: 401 or 500.

POST /api/notifications/read

Auth: Any signed-in user. The body is a union, so send exactly one form.

BodyTypeMeaning
idstring 1–64Mark this user's notification read.
allliteral trueMark all of this user's notifications read.
curl -X POST http://localhost:3000/api/notifications/read \
  -H 'content-type: application/json' -H 'cookie: next-auth.session-token=SESSION' \
  -d '{"id":"clx-notification"}'

Or { "all": true }. Success: { "data": { "updated": 1 } }; errors 401, malformed JSON 400, schema 400, and 500. An ID belonging to another user does not update another user's row.

GET /api/notifications/settings

Auth: Any signed-in user. Success: 200.

curl http://localhost:3000/api/notifications/settings \
  -H 'cookie: next-auth.session-token=SESSION'

Response shape:

{"data":{"settings":[{"kind":"SUBMISSION_MATCH","enabled":true,"techTags":""},{"kind":"ANNOUNCEMENT","enabled":true,"techTags":""},{"kind":"CALL_OPENED","enabled":true,"techTags":""},{"kind":"ENGAGEMENT_UPDATE","enabled":true,"techTags":""},{"kind":"SYSTEM","enabled":true,"techTags":""}]}}

Missing database rows are returned as enabled defaults. Errors: 401 or 500.

PUT /api/notifications/settings

Auth: Any signed-in user.

FieldTypeRequired?Constraints
settingsarrayYes1 through all notification kinds.
settings[].kindenumYesEvery NotificationKind value.
settings[].enabledbooleanYesExplicit true/false.

The implementation also accepts the optional tag filter where the settings UI sends it; tags are normalized lowercase and persisted in NotificationSetting.techTags.

curl -X PUT http://localhost:3000/api/notifications/settings \
  -H 'content-type: application/json' -H 'cookie: next-auth.session-token=SESSION' \
  -d '{"settings":[{"kind":"SUBMISSION_MATCH","enabled":true,"techTags":"uuv,autonomy"},{"kind":"ANNOUNCEMENT","enabled":false},{"kind":"CALL_OPENED","enabled":true},{"kind":"ENGAGEMENT_UPDATE","enabled":true},{"kind":"SYSTEM","enabled":true}]}'

Success: { "data": { "ok": true } }. Errors: 401, 400 for a malformed body or unknown kinds, and 500. Duplicate kinds are accepted; the last occurrence wins because settings are upserted in array order. A disabled kind suppresses future rows; it does not delete old notifications.

POST /api/notifications/reroute

Auth: admin-tier government session (GOV_ADMIN or GOV_SYSTEM_ADMIN). Success: 200.

curl -X POST http://localhost:3000/api/notifications/reroute \
  -H 'cookie: next-auth.session-token=ADMIN_SESSION'

The route finds SUBMITTED submissions and calls routeSubmission for each. Response: { "data": { "routed": 4, "totalMatches": 7 } }. Errors: 401, 403, 500. Reroute preserves match replacement behavior and notification de-duplication.

Notification kinds

KindTypical producerTypical recipient
SUBMISSION_MATCHrouteSubmissionGovernment POC or matching profile user.
ANNOUNCEMENTGovernment announcement publisherIndustry users or configured recipients.
CALL_OPENEDGovernment call publisherIndustry users.
ENGAGEMENT_UPDATEGovernment engagement workflowConfigured users when produced.
SYSTEMAdministrative/system pathUser-specific system recipient.

Read semantics

Marking a notification read is not deletion. The row, title, body, link, kind, and timestamp remain available in the paginated list. Mark-all updates rows belonging to the current user only. The unread count is a count query, so a stale browser can refresh it without downloading all notification bodies.

Preference semantics

A missing setting row behaves as enabled. An existing disabled row suppresses the matching future event. A nonempty tag filter is applied only when the event carries tags; it is lowercased and compared by exact comma-separated token after normalization. Changing a preference does not retroactively delete notifications or mark them read.

Safe client behavior

Treat notification IDs as opaque strings. A client should send only the ID or mark-all instruction supported by the route and should not attempt to update another user's row. The server derives the user ID from the session, so adding a userId property to a request cannot broaden the operation.

After a mutation, refresh the list and unread count rather than assuming every local badge update succeeded. A network retry is safe for an idempotent read; for a write, follow the route's documented body and inspect the response before retrying.

Example preference update

await fetch('/api/notifications/settings', {
  method: 'PUT',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    settings: [
      { kind: 'SUBMISSION_MATCH', enabled: true, techTags: 'ai, autonomy' },
      { kind: 'CALL_OPENED', enabled: false, techTags: '' }
    ]
  })
});

The array must contain at least one setting and cannot exceed the number of NotificationKind enum values. Tag filters reject control and injection-relevant characters before normalization. A successful update writes notification_settings_updated to the audit log.