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.
Authentication and Permissions
- All routes on this page require authentication.
- Everything is scoped to the authenticated user.
filtersandrulesare 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) andPUT /mode-packare additionally gated by@RequireFeature('notification_center')— whenENABLE_SUBSCRIPTIONS=true, the caller's plan must include thenotification_centerfeature or these return 403FEATURE_NOT_ENTITLED. Read-only routes (GET /filters,GET /rules,GET /mode-pack) are not gated. GET /:idis 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
| Method | Path | Purpose | Request or query | Auth |
|---|---|---|---|---|
GET | /api/notifications | List notifications. | Query: unreadOnly,archived,threadId,afterCursor | JWT or user API key |
GET | /api/notifications/:id | Get 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: id | JWT or user API key |
PATCH | /api/notifications/:id/read | Mark one notification read. | Path: id | JWT or user API key |
PATCH | /api/notifications/:id/unread | Mark one notification unread. | Path: id | JWT or user API key |
POST | /api/notifications/:id/act | Record an inline lifecycle action (confirm/snooze/delegate/claim/done/undo/ack) on one notification. | Path: id. Body: actionType,payload? | JWT or user API key |
POST | /api/notifications/read-all | Mark all notifications read. | None | JWT or user API key |
GET | /api/notifications/catalog | Read the notification catalog. | None | JWT or user API key |
GET | /api/notifications/scopes | Read available scopes for a type. | Query: type | JWT or user API key |
GET | /api/notifications/preferences | Read delivery preferences. | None | JWT or user API key |
PUT | /api/notifications/preferences | Replace delivery preferences. | Body: preferences | JWT or user API key |
GET | /api/notifications/digests | List past digest summaries (messages with eventType: 'digest.summary'). | None | JWT or user API key |
POST | /api/notifications/preview | Preview what a digest would currently contain, without sending or persisting anything. | None | JWT or user API key |
GET | /api/notifications/mode-pack | Read the caller's Mode Pack selection and feedback tuning. | None | JWT or user API key |
PUT | /api/notifications/mode-pack | Select (or clear) a Mode Pack; re-seeds that pack's default rules. | Body: modePack | JWT or user API key, notification_center feature |
POST | /api/notifications/feedback | One-tap "too much"/"too quiet" feedback for a priority class. | Body: class,direction | JWT or user API key |
POST | /api/notifications/devices | Register a push device. | Body: platform,token,userAgent | JWT or user API key |
DELETE | /api/notifications/devices/:deviceId | Delete a push device. | Path: deviceId | JWT or user API key |
GET | /api/notifications/push/vapid-public-key | Read the VAPID public key needed to create a browser push subscription. | None | JWT or user API key |
Filters and Rules
| Method | Path | Purpose | Request or query | Auth |
|---|---|---|---|---|
GET | /api/notifications/filters | List filter rules. | None | JWT or user API key |
POST | /api/notifications/filters | Create or update one filter. | Body: inbox rule payload | JWT or user API key |
PATCH | /api/notifications/filters | Replace or reorder filters. | Body: rules | JWT or user API key |
DELETE | /api/notifications/filters/:id | Delete one filter. | Path: id | JWT or user API key |
GET | /api/notifications/rules | List rules. | None | JWT or user API key |
POST | /api/notifications/rules | Create or update one rule. | Body: inbox rule payload | JWT or user API key |
PATCH | /api/notifications/rules | Replace or reorder rules. | Body: rules | JWT or user API key |
DELETE | /api/notifications/rules/:id | Delete one rule. | Path: id | JWT or user API key |
Mutes and Threads
| Method | Path | Purpose | Request or query | Auth |
|---|---|---|---|---|
GET | /api/notifications/mutes | List muted scopes. | None | JWT or user API key |
POST | /api/notifications/mutes | Create or update a mute. | Body: scopeType,scopeId,isMuted | JWT or user API key |
DELETE | /api/notifications/mutes/:scopeType/:scopeId | Remove one mute. | Path: scopeType,scopeId | JWT or user API key |
GET | /api/notifications/threads | List notification threads. | None | JWT or user API key |
PATCH | /api/notifications/threads/:id/mute | Mute one thread. | Path: id | JWT or user API key |
PATCH | /api/notifications/threads/:id/unmute | Unmute one thread. | Path: id | JWT or user API key |
PATCH | /api/notifications/threads/:id/archive | Archive one thread. | Path: id | JWT or user API key |
PATCH | /api/notifications/threads/:id/unarchive | Unarchive one thread. | Path: id | JWT or user API key |
Request Shapes
List query
ListNotificationsQueryDto
unreadOnly: optional booleanarchived: optional booleanthreadId: optional numberafterCursor: optional string
Preferences
UpdateNotificationPreferencesDto.preferences[]
eventType: required stringchannels: required object mapdigest: optional stringfallbackOrder: optional string arrayquietHours: optional object ornull
Device registration
RegisterDeviceDto
platform: requiredweb|ios|androidtoken: required stringuserAgent: optional string
Inline actions (act)
ActNotificationDto (POST /api/notifications/:id/act)
actionType: required, one ofconfirm|snooze|delegate|claim|done|undo|ack(NotificationActionTypeenum)payload: optional object — forsnooze, 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" | null—nullclears 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 ofcritical|time_critical|coordination|operational|fyi(NotificationClassenum)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, which PrimeCal never exposes, 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 numbername: required stringscopeType: requiredglobal|organisation|calendar|group|reservationscopeId: optionalisEnabled: required booleanconditions: required array of{ field, operator, value }actions: required array of{ type, payload }continueProcessing: optional booleanorder: optional number
UpdateInboxRulesDto.rules: required array of InboxRuleDto
Mutes
ScopeMuteDto
scopeType: requiredorganisation|calendar|group|reservation|resource|threadscopeId: required stringisMuted: required boolean
Invite event types
GET /api/notifications/catalog includes these people-group invite events:
| Event type | Purpose | Recommended channels | Scope |
|---|---|---|---|
calendar.group_invite.created | A user received an invite to join a calendar sharing people group. | inapp, email; fallback webpush | group |
calendar.group_invite.accepted | A recipient accepted an invite and joined the group. | inapp; fallback email | group |
calendar.group_invite.declined | A recipient declined an invite. | inapp; fallback email | group |
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. A seat on someone's plan offered on its own is announced as seat.invitation. All of these carry data.deepLink: "/app/invitations", where the invitation is answered (see Invitations API). They are published IN_APP_ONLY (notifications.constants.ts): the inbox row is the only thing the notification pipeline does for them, so the request that creates an invitation never waits on the delivery queue. The invitation email is sent directly to the address by the invitation service, so a user who enabled the email channel for the event type does not receive a second one - and, for the same reason, an invitation raises no push notification.
Reschedule requests
event.reschedule_requested ("Reschedule Requested", category event, inapp only) tells the owner of an event they marked as movable that someone else booked over it. It is raised by POST /api/events/:id/reschedule-notify (body: startsAtUtc, endsAtUtc of the saved event), which the booker's client calls after saving; the server picks the recipients itself - the owners, other than the caller, of timed, reschedulable events that directly overlap the saved event - and sends one notification per owner. The notification carries no plan: data holds blockingEventId, displacedEventIds, deepLink (/app/calendar?event=<displacedEventId>&reschedule=<blockingEventId>) and availableActions (snooze, done), and opening it makes the calendar plan afresh as the owner. It is classed coordination (it sits under "needs action" in the inbox) and published IN_APP_ONLY.
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/catalogis the safest source for building preference or rule editors.GET /api/notifications/scopesreturns the currently valid scope options for the requested notification type.- Existing-account people-group invites create in-app notifications immediately. Every invitation is also emailed to its address (an account or not), without revealing account existence to the inviter.
- Delivery is asynchronous. Every channel except
inappis delivered in the background.publish()saves the inbox message and itsinappdelivery (sent) first, then hands each other channel's delivery off and waits for the hand-off for at most 3 seconds. If the hand-off does not complete in time, or is refused, that one delivery is recorded asfailedwithlastErrorstartingqueue_unavailable:andmetadata.queued: false; the notification and every other recipient's notification stand, and the request carries on. A caller that a request waits on passespreferredChannels: IN_APP_ONLYand avoids background delivery altogether - people-group and seat invitations do. - A notification can carry its own same-origin
data.deepLink;GET /api/notifications/v2/inboxuses it for the row'sdeepLinkwhen the notification's subject resolver provides none. Anything that is not a plain path inside the app (//host,https:, a backslash, whitespace) is dropped. - 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.remindernotifications are not published through any route on this page. They're fired automatically byEventReminderSchedulerService(a per-minute cron job) whenever an event'sremindersarray 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, andackRequiredare always present on the returned message (see the Notification Classes Reference for exactly how each is derived). An inbox rule'sset_priority/route_channels/hold_for_gap/batch_into_digestaction 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" }), andescalate_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_digestrule 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 thesuppress_notificationrule action, which skips creating the message for that recipient entirely. data.availableActionson a notification (an array ofNotificationActionTypevalues) drives which inline-action buttons a client should render; if a publisher didn't set it explicitly,NotificationsServicefills in a default set based on the notification's priority class.
Best Practices
- Use
afterCursorfor 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 rawPATCH /: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 /previewas read-only tooling for a "what would my next digest look like" UI affordance — it's not a way to trigger delivery.