payload-notifications
Multi-channel notification infrastructure for Payload CMS with document subscriptions and live messages.

Overview
Payload has no built-in way to notify users about events. This plugin provides a server-side notify() / subscribe() / getSubscribers() API that you wire into your own hooks, endpoints, and workflows. A single notify() call can store an in-app notification, send an email, and fire an external callback -- each channel independent and optional. Everything is user-scoped with strict access control.
Features
- Multi-channel delivery -- in-app, email, and external callbacks from a single
notify() call. Per-user preferences control which channels are active.
- Document subscriptions -- users follow collections or globals.
getSubscribers() returns follower IDs for fan-out. Supports auto-subscribe (e.g. on first comment) and manual follows.
- Live messages -- template tokens (
t.actor, t.document('title'), t.meta('key')) that resolve against fresh data at read time. Renamed users and updated titles are reflected in existing notifications.
- External callback --
onNotify hook for pushing to Slack, webhooks, queues, or anything else.
- Admin bell component -- optional popover UI with unread badge, mark-read, unsubscribe, and delete actions.
Installation
pnpm add payload-notifications
Usage
import { buildConfig } from "payload";
import { notificationsPlugin } from "payload-notifications";
export default buildConfig({
plugins: [
notificationsPlugin({
email: {
generateSubject: ({ notification }) =>
`Notification: ${notification.event}`,
generateHTML: ({ notification, recipient }) =>
`<p>Hi ${recipient.displayName}, ${notification.message}</p>`,
},
}),
],
});
Sending notifications
import { notify } from "payload-notifications";
await notify(req, {
recipient: user.id,
event: "comment.created",
actor: commenter.id,
message: "Someone replied to your post",
documentReference: { entity: "collection", slug: "posts", id: post.id },
});
Subscriptions and fan-out
import {
subscribe,
getSubscribers,
notify,
createLiveMessage,
} from "payload-notifications";
const docRef = { entity: "collection", slug: "posts", id: post.id } as const;
await subscribe(req, { userId: commenter.id, documentReference: docRef });
const subscribers = await getSubscribers(req, docRef);
for (const recipientId of subscribers) {
await notify(req, {
recipient: recipientId,
event: "comment.created",
actor: commenter.id,
message: createLiveMessage(
(t) => t`${t.actor} commented on "${t.document("title")}"`,
),
documentReference: docRef,
});
}
Live messages
Static strings go stale when users rename themselves or documents get updated. Live messages store tokens that resolve against fresh data at read time:
import { notify, createLiveMessage } from "payload-notifications";
await notify(req, {
recipient: user.id,
event: "post.updated",
actor: editor.id,
message: createLiveMessage(
(t) => t`${t.actor} edited "${t.document("title")}"`,
),
documentReference: { entity: "collection", slug: "posts", id: post.id },
});
t.actor -- actor's display name (from admin.useAsTitle on the user collection)
t.document(field) -- a field from the referenced document
t.meta(key) -- a key from the notification's meta object
Options
email | NotificationEmailConfig | -- | generateSubject and generateHTML functions. Omit to skip email. |
onNotify | NotifactionCallback | -- | Callback for every notification (Slack, webhooks, queues, etc). |
notificationsSlug | string | "notifications" | Slug for the notifications collection. |
subscriptionsSlug | string | "subscriptions" | Slug for the subscriptions collection. |
pollInterval | number | 30 | Poll interval in seconds for the admin bell component. |
API
All functions are server-side and require a PayloadRequest:
notify(req, input) | Deliver a notification. Respects per-user channel preferences. |
subscribe(req, opts) | Subscribe a user to a document. Deduplicates automatically. |
unsubscribe(req, userId, ref) | Remove a subscription. |
getSubscribers(req, ref) | Return all user IDs subscribed to a document. |
createLiveMessage(fn) | Build a serializable message template with dynamic tokens. |
Contributing
This plugin lives in the payload-plugins monorepo.
Development
pnpm install
pnpm --filter payload-notifications dev
pnpm --filter sandbox dev
The sandbox/ directory is a Next.js + Payload app that imports plugins via workspace:* -- use it to test changes locally.
Code quality
- Formatting & linting -- handled by Biome, enforced on commit via husky + lint-staged.
- Commits -- must follow Conventional Commits with a valid scope (e.g.
fix(payload-notifications): ...).
- Changesets -- please include a changeset in your PR by running
pnpm release.
Issues & PRs
Bug reports and feature requests are welcome -- open an issue.
License
MIT