Skip to main content
Was this helpful?

Notifications API

Inbox and Delivery Controls

Read notifications, tune delivery, register devices, and shape inbox rules

These routes power the signed-in notification inbox, delivery preferences, push-device registry, filters and rules, mute scopes, and thread-level actions.

JWT or user API keyInbox filteringPush devicesRules and mutes

Authentication and Permissions

  • All routes on this page require authentication.
  • Everything is scoped to the authenticated user.
  • filters and rules are parallel route families for the same underlying concept in the current controller surface.
  • People-group invite notifications use the same inbox, thread, mute, and catalog surfaces as existing calendar notifications.
  • Routes that create or mutate inbox rules (POST/PATCH/DELETE /filters, POST/PATCH/DELETE /rules) and PUT /mode-pack are additionally gated by @RequireFeature('notification_center') — when ENABLE_SUBSCRIPTIONS=true, the caller's plan must include the notification_center feature or these return 403 FEATURE_NOT_ENTITLED. Read-only routes (GET /filters, GET /rules, GET /mode-pack) are not gated.
  • GET /:id is registered last in the controller specifically so it doesn't swallow the literal routes above it (catalog, scopes, preferences, digests, mode-pack, filters, rules, etc.) — this only matters if you're extending the controller, not for API consumers.

Endpoint Reference

Inbox and Delivery

MethodPathPurposeRequest or queryAuthSource
GET/api/notificationsList notifications.Query: unreadOnly,archived,threadId,afterCursorJWT or user API keynotifications/notifications.controller.ts
GET/api/notifications/:idGet one notification's full detail (priority class, score, routing decision, ack state, data/metadata). Registered last so it can't shadow the literal routes below it.Path: idJWT or user API keynotifications/notifications.controller.ts
PATCH/api/notifications/:id/readMark one notification read.Path: idJWT or user API keynotifications/notifications.controller.ts
PATCH/api/notifications/:id/unreadMark one notification unread.Path: idJWT or user API keynotifications/notifications.controller.ts
POST/api/notifications/:id/actRecord an inline lifecycle action (confirm/snooze/delegate/claim/done/undo/ack) on one notification.Path: id. Body: actionType,payload?JWT or user API keynotifications/notifications.controller.ts
POST/api/notifications/read-allMark all notifications read.NoneJWT or user API keynotifications/notifications.controller.ts
GET/api/notifications/catalogRead the notification catalog.NoneJWT or user API keynotifications/notifications.controller.ts
GET/api/notifications/scopesRead available scopes for a type.Query: typeJWT or user API keynotifications/notifications.controller.ts
GET/api/notifications/preferencesRead delivery preferences.NoneJWT or user API keynotifications/notifications.controller.ts
PUT/api/notifications/preferencesReplace delivery preferences.Body: preferencesJWT or user API keynotifications/notifications.controller.ts
GET/api/notifications/digestsList past digest summaries (messages with eventType: 'digest.summary').NoneJWT or user API keynotifications/notifications.controller.ts
POST/api/notifications/previewPreview what a digest would currently contain, without sending or persisting anything.NoneJWT or user API keynotifications/notifications.controller.ts
GET/api/notifications/mode-packRead the caller's Mode Pack selection and feedback tuning.NoneJWT or user API keynotifications/notifications.controller.ts
PUT/api/notifications/mode-packSelect (or clear) a Mode Pack; re-seeds that pack's default rules.Body: modePackJWT or user API key, notification_center featurenotifications/notifications.controller.ts
POST/api/notifications/feedbackOne-tap "too much"/"too quiet" feedback for a priority class.Body: class,directionJWT or user API keynotifications/notifications.controller.ts
POST/api/notifications/devicesRegister a push device.Body: platform,token,userAgentJWT or user API keynotifications/notifications.controller.ts
DELETE/api/notifications/devices/:deviceIdDelete a push device.Path: deviceIdJWT or user API keynotifications/notifications.controller.ts
GET/api/notifications/push/vapid-public-keyRead the VAPID public key needed to create a browser push subscription.NoneJWT or user API keynotifications/notifications.controller.ts

Filters and Rules

MethodPathPurposeRequest or queryAuthSource
GET/api/notifications/filtersList filter rules.NoneJWT or user API keynotifications/notifications.controller.ts
POST/api/notifications/filtersCreate or update one filter.Body: inbox rule payloadJWT or user API keynotifications/notifications.controller.ts
PATCH/api/notifications/filtersReplace or reorder filters.Body: rulesJWT or user API keynotifications/notifications.controller.ts
DELETE/api/notifications/filters/:idDelete one filter.Path: idJWT or user API keynotifications/notifications.controller.ts
GET/api/notifications/rulesList rules.NoneJWT or user API keynotifications/notifications.controller.ts
POST/api/notifications/rulesCreate or update one rule.Body: inbox rule payloadJWT or user API keynotifications/notifications.controller.ts
PATCH/api/notifications/rulesReplace or reorder rules.Body: rulesJWT or user API keynotifications/notifications.controller.ts
DELETE/api/notifications/rules/:idDelete one rule.Path: idJWT or user API keynotifications/notifications.controller.ts

Mutes and Threads

MethodPathPurposeRequest or queryAuthSource
GET/api/notifications/mutesList muted scopes.NoneJWT or user API keynotifications/notification-mutes.controller.ts
POST/api/notifications/mutesCreate or update a mute.Body: scopeType,scopeId,isMutedJWT or user API keynotifications/notification-mutes.controller.ts
DELETE/api/notifications/mutes/:scopeType/:scopeIdRemove one mute.Path: scopeType,scopeIdJWT or user API keynotifications/notification-mutes.controller.ts
GET/api/notifications/threadsList notification threads.NoneJWT or user API keynotifications/notification-threads.controller.ts
PATCH/api/notifications/threads/:id/muteMute one thread.Path: idJWT or user API keynotifications/notification-threads.controller.ts
PATCH/api/notifications/threads/:id/unmuteUnmute one thread.Path: idJWT or user API keynotifications/notification-threads.controller.ts
PATCH/api/notifications/threads/:id/archiveArchive one thread.Path: idJWT or user API keynotifications/notification-threads.controller.ts
PATCH/api/notifications/threads/:id/unarchiveUnarchive one thread.Path: idJWT or user API keynotifications/notification-threads.controller.ts

Request Shapes

List query

ListNotificationsQueryDto

  • unreadOnly: optional boolean
  • archived: optional boolean
  • threadId: optional number
  • afterCursor: optional string

Preferences

UpdateNotificationPreferencesDto.preferences[]

  • eventType: required string
  • channels: required object map
  • digest: optional string
  • fallbackOrder: optional string array
  • quietHours: optional object or null

Device registration

RegisterDeviceDto

  • platform: required web|ios|android
  • token: required string
  • userAgent: optional string

Inline actions (act)

ActNotificationDto (POST /api/notifications/:id/act)

  • actionType: required, one of confirm|snooze|delegate|claim|done|undo|ack (NotificationActionType enum)
  • payload: optional object — for snooze, either { snoozeUntil: <ISO string or epoch ms> } or { snoozeMinutes: <number> }; if neither is given, snooze defaults to 30 minutes from now

Calling act also implicitly marks the notification read if it wasn't already. done/confirm archive the notification. snooze archives it and schedules a resurface job (unarchives it again at the resolved snooze time). undo un-archives it. The created NotificationAction row (id, notificationId, userId, actionType, actionPayload, actedAt) is returned as-is.

Mode Pack and Feedback

PUT /api/notifications/mode-pack body:

  • modePack: "focus_adhd" | "work_store" | "family" | nullnull clears the selection

Selecting a pack (re)seeds a small set of global inbox rules owned by that pack (tagged internally via a packOwned column so switching packs never touches your own hand-authored rules) and removes whichever rules the previous pack had seeded. family currently seeds no rules (no child/view-only role exists yet in the platform), so selecting it only records the preference. Returns the saved UserNotificationSettings row (userId, modePack, feedbackTuning, createdAt, updatedAt).

GET /api/notifications/mode-pack returns the same shape, or null if the user has never set one.

POST /api/notifications/feedback body:

  • class: required, one of critical|time_critical|coordination|operational|fyi (NotificationClass enum)
  • direction: required, "too_much" | "too_quiet"

too_much nudges that class one tier quieter for future notifications (toward hold_for_gap/batch_digest routing); too_quiet nudges it one tier louder (toward interrupt_now). critical is never adjustable this way — the triage engine always ignores feedback tuning for that class. Returns the updated UserNotificationSettings row with the new feedbackTuning map (e.g. { "operational": -1 }).

Digests and preview

GET /api/notifications/digests returns up to the 50 most recent messages with eventType: 'digest.summary', newest first — these are the same shape as any other item from GET /api/notifications.

POST /api/notifications/preview returns { count: number, items: Array<{ title: string; body: string }> } — a live snapshot of currently scheduled, digest-tagged email deliveries for the caller. It doesn't send anything or persist a message; it's read-only preview of what the next digest email would contain if it fired right now.

VAPID public key

GET /api/notifications/push/vapid-public-key returns { publicKey: string | null }. The public key is safe to expose to any authenticated user — only the paired private key (WEBPUSH_VAPID_PRIVATE_KEY, admin-configured, encrypted at rest) is sensitive. publicKey is null when no VAPID key pair has been configured yet, in which case the client should treat web push as unavailable rather than attempting to subscribe. The frontend's usePushSubscription hook (frontend/src/hooks/usePushSubscription.ts) converts this key to the Uint8Array shape PushManager.subscribe() expects and registers the resulting subscription via POST /api/notifications/devices with platform: "web".

Filters and rules

InboxRuleDto

  • id: optional number
  • name: required string
  • scopeType: required global|organisation|calendar|group|reservation
  • scopeId: optional
  • isEnabled: required boolean
  • conditions: required array of { field, operator, value }
  • actions: required array of { type, payload }
  • continueProcessing: optional boolean
  • order: optional number

UpdateInboxRulesDto.rules: required array of InboxRuleDto

Mutes

ScopeMuteDto

  • scopeType: required organisation|calendar|group|reservation|resource|thread
  • scopeId: required string
  • isMuted: required boolean

Invite event types

GET /api/notifications/catalog includes these people-group invite events:

Event typePurposeRecommended channelsScope
calendar.group_invite.createdA user received an invite to join a calendar sharing people group.inapp, email; fallback webpushgroup
calendar.group_invite.acceptedA recipient accepted an invite and joined the group.inapp; fallback emailgroup
calendar.group_invite.declinedA recipient declined an invite.inapp; fallback emailgroup

Created-invite notifications include group name, inviter identity, and a short invite-message snippet for the intended recipient. They must not expose invite lookup behavior to unrelated users.

Example Calls

List unread notifications

curl "$PRIMECAL_API/api/notifications?unreadOnly=true" \
-H "Authorization: Bearer $TOKEN"

Update preferences

curl -X PUT "$PRIMECAL_API/api/notifications/preferences" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"preferences": [
{
"eventType": "event.reminder",
"channels": {
"inapp": true,
"email": false,
"webpush": true
},
"digest": "immediate",
"fallbackOrder": ["webpush"]
}
]
}'

Register a device

curl -X POST "$PRIMECAL_API/api/notifications/devices" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"platform": "web",
"token": "push-token-example",
"userAgent": "Chrome 135"
}'

Acknowledge a Critical notification

curl -X POST "$PRIMECAL_API/api/notifications/482/act" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "actionType": "ack" }'

Snooze a notification for 2 hours

curl -X POST "$PRIMECAL_API/api/notifications/482/act" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "actionType": "snooze", "payload": { "snoozeMinutes": 120 } }'

Select a Mode Pack

curl -X PUT "$PRIMECAL_API/api/notifications/mode-pack" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "modePack": "focus_adhd" }'

Send "too quiet" feedback for Operational notifications

curl -X POST "$PRIMECAL_API/api/notifications/feedback" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "class": "operational", "direction": "too_quiet" }'

Create a mute

curl -X POST "$PRIMECAL_API/api/notifications/mutes" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scopeType": "calendar",
"scopeId": "12",
"isMuted": true
}'

Response and Behavior Notes

  • GET /api/notifications/catalog is the safest source for building preference or rule editors.
  • GET /api/notifications/scopes returns the currently valid scope options for the requested notification type.
  • Existing-account people-group invites create in-app notifications immediately; external email invites are stored for future delivery without revealing account existence to the inviter.
  • Filter and rule routes are both active in the controller surface; treat them as parallel entry points to the same model.
  • Device deletion and mute deletion return success-style responses rather than rich objects.
  • event.reminder notifications are not published through any route on this page. They're fired automatically by EventReminderSchedulerService (a per-minute cron job) whenever an event's reminders array has an entry due to fire — see Event API for how reminders are set on an event, and Sync API for how they get populated automatically from Google/Outlook on import.
  • Every notification created via NotificationsService.publish() is classified synchronously by the triage engine before it's saved — notificationClass, priorityScore, routingDecision, and ackRequired are always present on the returned message (see the Notification Classes Reference for exactly how each is derived). An inbox rule's set_priority/route_channels/hold_for_gap/batch_into_digest action can override the engine's default per-notification.
  • Filter/rule bodies also accept these newer action types beyond the original suppress_notification|suppress_channels|archive|mark_read|mark_unread|mute_thread: set_priority (payload { class }), route_channels (payload { channels }), hold_for_gap (no payload), batch_into_digest (payload { cadence: "hourly"|"daily" }), and escalate_after (payload { minutes, target: "group_elevated"|"org_admin", targetId } — not yet exposed in the frontend's rule builder UI, API/agent-only for now).
  • Critical and Time-critical notifications are never delayed, held, or batched into a digest, regardless of digest/quiet-hours preferences or hold_for_gap/batch_into_digest rule overrides.
  • Scope mutes and thread mutes both result in the message being created, archived, and marked read silently (metadata.evaluation.silent: true) rather than the message never existing — that's different from the suppress_notification rule action, which skips creating the message for that recipient entirely.
  • data.availableActions on a notification (an array of NotificationActionType values) drives which inline-action buttons a client should render; if a publisher didn't set it explicitly, NotificationsService fills in a default set based on the notification's priority class.

Best Practices

  • Use afterCursor for incremental inbox loading instead of fetching a large unbounded list.
  • Build rule editors from the live catalog and scope endpoints instead of hard-coding event types.
  • Keep device registration idempotent in the client. The backend can reuse an existing token association.
  • Prefer mutes for temporary suppression and rules for long-lived routing or archive behavior.
  • Expose thread actions separately in the UI. Thread mute/archive is a different concept from scope-level mute settings.
  • Always call act (not a raw PATCH /:id/read) when the user takes a real lifecycle action like confirming or claiming something — it marks the notification read as a side effect, so a separate read call isn't needed.
  • Treat POST /preview as read-only tooling for a "what would my next digest look like" UI affordance — it's not a way to trigger delivery.