Notifications
The per-user inbox for collaboration and publishing events.
Notifications are a per-user inbox. When something happens that a user should know about (someone mentions them, an approval lands, content ships) the CMS writes a row addressed to that user, so reviewers and editors keep up without polling a dozen resources.
Every notification belongs to exactly one recipient. A comment that mentions three people raises three notifications, one in each inbox. Every read and mutation the API exposes is scoped to the caller: you only ever see or touch your own, and acting on someone else's notification fails with NOTIFICATION_RECIPIENT_MISMATCH.
What triggers a notification
The CMS raises a notification on:
- Mentions: being
@-mentioned in a comment. - Comments: new messages, and resolved or reopened threads, on a thread you are part of.
- Approvals: an approval requested from you, or your own request being approved or rejected.
- Merge requests: opened, merged, closed, or reopened.
- Publishing: content published.
Each source maps to a type. The core set runs from mention and comment through the approval, merge-request, and published events; the full list is in the reference. Treat type as an open string when you branch on it, so an unfamiliar value degrades gracefully.
Anatomy of a notification
A row carries everything a UI needs to render a line item and link it back to its source:
type,title, andbody: the kind of event and its human-readable text.actorId: the user who caused it (nullfor system events). See actor enrichment below.resourceType,resourceId, andcollection: what the notification points at (acommentThread, amergeRequest, a publishedroot) and where it lives.meta: a free-form JSON bag of per-type extras. Amention, for example, carries itsthreadIdandmessageId. Together with the resource fields it is enough to build a deep link back to the thing that changed.readAt: the timestamp it was read, ornullwhile unread.
For the exact field types, see the list reference.
Reading the inbox
list returns the recipient's notifications newest first, archived ones excluded, plus a total, a hasMore paging flag, and an unreadCount:
// the recipient's inbox, newest first (archived rows excluded)
const { notifications, unreadCount, hasMore } = await cms.api.notifications.list({
query: { limit: 20 },
});
// narrow it: just the unread mentions
const { notifications: mentions } = await cms.api.notifications.list({
query: { unreadOnly: true, type: 'mention' },
});unreadCount is the count of all unread notifications in the inbox, independent of the current page and any filter, so it drives a badge on its own. Narrow the list with unreadOnly, type, or collection, and page with limit / offset.
Read, unread, and archived
A notification moves along two independent axes, and it is worth keeping them apart:
- Read vs unread is a reversible toggle on
readAt.markNotificationsReadsets it,markNotificationsUnreadclears it. Both take an optionalnotificationId: pass one to flip a single row, omit it to flip the whole inbox (mark-all-read is the no-argument call). - Active vs archived is a one-way move on
archivedAt.archiveNotificationremoves a notification from the inbox for good:listnever returns archived rows, and there is no unarchive endpoint. Archiving is how a user says "I am done with this," where marking read just says "I have seen it."
// mark one as read
await cms.api.notifications.markNotificationsRead({
body: { notificationId: 'notif_8fd21c' },
});
// clear the whole inbox in one call
await cms.api.notifications.markNotificationsRead();
// drop one for good
await cms.api.notifications.archiveNotification({
body: { notificationId: 'notif_8fd21c' },
});Because the two axes are independent, unreadCount counts only rows that are both unread and still active: archiving an unread notification also takes it out of the badge.
The responsible user
Every notification stores the actorId of whoever triggered it, but an id alone cannot render "Ada approved your request." Pass withUser: true in the query and each item gains an actorUser object with that user's exposed columns, drawn from the same exposeColumns allowlist the rest of the CMS uses, so private fields never leak:
const { notifications } = await cms.api.notifications.list({
query: { withUser: true, limit: 20 },
});
notifications[0]?.actorUser?.name; // typed off your `user` config, not `unknown`actorUser is typed from your user config, so the columns you exposed autocomplete. With realtime enabled the live push carries actorUser on the wire too, so a pushed notification can render its actor's name and avatar immediately, with no follow-up fetch.
Delivery
Notifications are written after the triggering action commits, and they are fire-and-forget: a rolled-back change raises nothing, and a notification failure never rolls back the action that caused it.
list is the durable path and is always available. With realtime configured, notifications are also pushed to each recipient the moment they are raised, and the useNotifications hook layers those live pushes on top of the poll. Without realtime there is no built-in polling hook: you call list on your own cadence. See realtime for the push model and the notification bell.
Turning it off
Set notifications: false on createCMS to remove the feature outright:
const cms = createCMS({
// …
notifications: false,
});This is a hard, type-level disable: the notification tables are not generated (regenerate your schema after toggling), the routes never mount, and cms.notify plus the whole cms.api.notifications namespace disappear from the inferred types, so a stray call is a compile error rather than a runtime surprise. Use a literal false: a value widened to boolean keeps the types enabled (the runtime still honors it at request time). notifications and realtime are independent, so A/B live results can run on realtime with notifications off.
For exact method signatures and the full type list, see the Notifications reference, and configuration for the switch.