Sending Notifications
Overview
Sending happens in two steps. Notification() starts a builder you fill field by field; .build() closes it and gives you a SendableNotification, which carries the dispatch methods.
For the visible result, see the single-recipient notification and second-session update. The walkthrough distinguishes delivery from read state and shows a disabled subject preventing a new delivery.
import {
GeneralSubject,
Notification,
} from "@antelopejs/interface-dms/notifications";
await Notification()
.icon("i-ph-bell")
.title("Deployment finished")
.description("The staging deployment completed successfully.")
.subject(GeneralSubject)
.build()
.toUser(userId);
A SendableNotification is immutable and holds no recipient, so you can build once and dispatch several times.
| Method | Signature | Description |
|---|---|---|
toUser | (userId: string, options?: SendOptions) => Promise<void> | Send to one user. |
toUsers | (userIds: string[], options?: SendOptions) => Promise<void> | Send to a list of users. |
toUsersIdempotently | (userIds: string[], idempotencyKey: string, options?: SendOptions) => Promise<void> | Send a durable event once per recipient, including after dismissal. |
toRoles | (roleIds: string[], options?: SendOptions) => Promise<void> | Send to every user holding at least one of the roles. |
broadcast | (options?: SendOptions) => Promise<void> | Send to every user. |
Building a Notification
Notification() takes no arguments and returns a fresh NotificationBuilder. Every setter stores its value and returns the builder, so calls chain in any order.
import { Notification } from "@antelopejs/interface-dms/notifications";
import { orderSubject } from "./notifications";
const shipped = Notification()
.icon("i-ph-truck")
.title("$shop.notifications.order_shipped.title")
.description("$shop.notifications.order_shipped.description")
.params({ orderRef: "A-1043" })
.subject(orderSubject)
.linkTo("/shop/orders")
.build();
| Method | Type | Description |
|---|---|---|
.icon(value) | string | Icon shown next to the notification, in the dashboard icon syntax (for example i-ph-truck). Required. |
.title(value) | string | Title line. Required. |
.description(value) | string | Body line. Required. |
.subject(subject) | NotificationSubjectInfo | The subject the notification is filed under, which decides the category and the user preference it is gated on. Required. |
.linkTo(value) | string | Destination the notification links to, such as /settings/user/profile. Optional. |
.params(value) | Record<string, string | number> | Interpolation values carried alongside the notification. Optional. |
.tone(value) | NotificationTone ("neutral", "primary", "success", "warning", "error") | Colour of the icon well the inbox lists the notification with. Optional: without it the well is primary while unread and neutral once read. |
The subject comes from Categories and Subjects — either one you declared with NotificationSubject() or one of the built-in constants.
Titles and descriptions can be literal text or, following the DMS translation convention, a $-prefixed translation key; in the latter case the values passed to .params() are the interpolation values for that key. The DMS's own account notifications are written that way:
import {
Notification,
SecuritySubject,
} from "@antelopejs/interface-dms/notifications";
await Notification()
.icon("i-ph-sign-in")
.title("$dms.notifications.messages.new_login.title")
.description("$dms.notifications.messages.new_login.description")
.params({ device: "Firefox on Linux", origin: " (203.0.113.4)" })
.subject(SecuritySubject)
.linkTo("/settings/user/profile")
.build()
.toUser(userId);
Required Fields Are Checked by the Type System
NotificationBuilder<Set> tracks in its type parameter which fields you have supplied: .icon() returns NotificationBuilder<Set | "icon">, .title() adds "title", and so on. build() is declared with a this parameter that only accepts a builder whose Set covers RequiredFields:
type RequiredFields = "icon" | "title" | "description" | "subject";
So a chain that skips one of the four does not compile — this is typed never at the build() call:
// Compile error: description and subject were never supplied
const broken = Notification().icon("i-ph-bell").title("Hello").build();
The check is purely static and happens at the call site. Nothing is validated again at runtime, and .linkTo() and .params() never affect it, and neither does .tone().
build() returns a SendableNotification holding a frozen copy of the data, so reusing the builder afterwards cannot change a notification you already built.
Sending to a Single User
toUser(userId, options?) sends one copy to one user. It accepts an idempotency key; readScope has no effect for this method.
import { Controller, Post } from "@antelopejs/interface-api";
import { AuthRawUser } from "@antelopejs/interface-dms/auth";
import type { User } from "@antelopejs/interface-dms/auth/db";
import {
GeneralSubject,
Notification,
} from "@antelopejs/interface-dms/notifications";
export class NotificationDemoController extends Controller("/api/notification") {
@Post("send")
async send(@AuthRawUser() user: User) {
await Notification()
.icon("i-ph-bell")
.title("Notification sent")
.description("This notification was sent from a route handler.")
.subject(GeneralSubject)
.build()
.toUser(user._id);
return { success: true };
}
}
Sending to Several Users
toUsers(userIds, options?) sends to a list of user ids. Duplicate ids in the list produce a single notification per user. The examples in this section and the two below dispatch the shipped notification built earlier on this page.
await shipped.toUsers([ownerId, managerId], { readScope: "shared" });
Sending to Roles
toRoles(roleIds, options?) resolves the users that hold any of the given roles and sends to them. An empty roleIds array sends nothing, and so does a set of roles nobody holds.
await shipped.toRoles([adminRoleId], { readScope: "shared" });
Role ids are the DMS role identifiers described in the DMS tenancy and permissions guide; membership is resolved across tenants, so a user holding the role in any tenant is included.
Broadcasting
broadcast(options?) sends to every user.
await shipped.broadcast();
Send Options
Every dispatch method accepts an optional SendOptions object.
| Option | Type | Description |
|---|---|---|
readScope | ReadScope (optional) | Read-state grouping for a multi-recipient send. Defaults to "individual" — see Read Scope. |
idempotencyKey | string (optional) | Stable event key, unique across producers: prefix it with your module ID. Reusing it for a recipient preserves the original notification and dismissal state. |
dedupe | boolean (optional) | false stores every unkeyed send, even one identical to a notification the recipient got less than 10 seconds earlier. Defaults to true; see below. |
Pick "shared" for alerts that only need one person to act on them, and "individual" for anything each recipient has to see for themselves.
import type { SendOptions } from "@antelopejs/interface-dms/notifications/types";
const forEveryone: SendOptions = { readScope: "individual" };
const handleOnce: SendOptions = { readScope: "shared" };
Repeated Sends
An unkeyed notification identical to one the same user received less than 10 seconds earlier is dropped: same recipient, title, description, params, link, category, subject, tone and icon. A form submitted twice, or a hook firing on every instance, leaves one row; two notices worded alike but filed under different subjects, or drawn with a different tone or icon, are two notices. A sender whose repeats are all meant to arrive (a counter ticking, a chat) passes { dedupe: false } to any send method. The check holds across instances: the row ID derives from the recipient, the content and a 10-second window, so the database refuses the second insert, and a copy from the window before also counts. The same notification sent again later arrives as usual, and so does one whose params differ (a count, a timestamp). Keyed sends are deduplicated by their key instead, as described below.
Retrying Durable Events
Use the same event key on every outbox retry. The first delivery stored under a key wins: a later send under the same key returns without storing anything, even when its content differs (a count recomputed on retry, a timestamp), so a producer that keys a notification per period does not have to guard against a duplicate-key error. The explicit method is equivalent to passing idempotencyKey to toUsers; its key argument takes precedence over a key in options.
The key is shared by every module of the deployment: the DMS stores one row per key and recipient, whatever module or category sent it. Because a reused key counts as already delivered, two modules that happen to build the same key for the same user would lose the second notification without any error. Namespace your keys with your module ID (mailing:digest:2026-10-13T09, not digest:2026-10-13T09).
await shipped.toUsers([ownerId, managerId], { idempotencyKey: event.id });
await shipped.toUsersIdempotently([ownerId, managerId], event.id);
The DMS derives a namespaced SHA-256 row ID from the event key and recipient ID and performs a plain atomic insert. After an insert error, it reads that exact ID and accepts only a matching stored delivery, including its subject, content, and read group. Other errors propagate so an outbox can retry; duplicate attempts never replace the row or reset its timestamps or read state.
Single and bulk dismissal retain keyed rows as hidden delivery receipts. Lists, previews, counts, individual lookups, and read operations exclude these rows; retries do not recreate them. Unkeyed notifications retain physical deletion and fresh delivery on every send outside the 10-second repeat window. Receipts retain notification content indefinitely: do not hard-delete or expire these rows while their event keys can still be retried, and account for this retention when designing data-erasure policies.
Shared-read sends derive a stable group ID from the event key. Recipient membership and preferences are evaluated again on each attempt, so a retry can add previously missing recipients without duplicating successful deliveries. A recipient added after another recipient has already marked the group read starts unread; retries preserve existing recipients' state rather than replaying past read actions.
Realtime publishing remains best-effort and occurs only after a newly inserted delivery. A crash or lost acknowledgement can leave a stored notification without a realtime event; retrying does not replay realtime. Delivery gates still apply, and skipped recipients do not receive receipts.
Consumers that support older DMS packages must fail closed instead of passing an option an old runtime silently ignores. Feature-detect the explicit method and retain pending outbox work until the DMS package and runtime are upgraded together to a version containing it:
if (!("toUsersIdempotently" in shipped) || typeof shipped.toUsersIdempotently !== "function") {
throw new Error("DMS idempotent notification delivery is unavailable");
}
await shipped.toUsersIdempotently([ownerId, managerId], event.id);
Every keyed dispatch also checks the implementation's SupportsIdempotency interface capability before sending. A new local builder paired with an old DMS implementation therefore rejects rather than silently delivering without deduplication.
Delivery Rules
The DMS applies the same rules to every dispatch method, since toUsers, toRoles and broadcast all resolve to per-user deliveries:
- The subject must be registered. A notification whose subject was never declared through
NotificationSubject()is dropped. This is what makes registration order matter — see Declaration Order. - The user preference is honored. Delivery is skipped when the recipient has turned off the
categoryId:subjectIdpair in their notification settings. Preferences default to enabled, so a subject a user has never touched is delivered. - Skipping is silent. A gated-out delivery is not an error: the promise resolves normally, and there is no per-recipient result to inspect.
- Delivery is per recipient. Each recipient gets their own stored notification. With
readScope: "shared"those copies are tied together for read state; they are still separate rows. - Stored notifications are pushed live. Each delivery is also published on the recipient's realtime stream, so the dashboard's badge and preview update without a reload.
Because a send can be skipped for reasons outside your control, treat notifications as best-effort: do not make a workflow depend on one having been stored. Wrapping the call so a failure cannot break the operation that triggered it is a common pattern:
import { Logging } from "@antelopejs/interface-core/logging";
import {
AccountSubject,
Notification,
} from "@antelopejs/interface-dms/notifications";
export async function notifyWelcome(userId: string, name: string) {
try {
await Notification()
.icon("i-ph-hand-waving")
.title("$dms.notifications.messages.welcome.title")
.description("$dms.notifications.messages.welcome.description")
.params({ name })
.subject(AccountSubject)
.linkTo("/settings/user/profile")
.build()
.toUser(userId);
} catch (error) {
Logging.Error(`Failed to notify "${userId}": ${String(error)}`);
}
}
Importing the Types
Notification() is exported from the interface root, and the data types from the types subpath. The two classes are exported from the modules that define them, which you only need when you want to name them explicitly:
| Symbol | Import from |
|---|---|
Notification | @antelopejs/interface-dms/notifications |
NotificationData, SendOptions, RequiredFields | @antelopejs/interface-dms/notifications/types |
NotificationBuilder | @antelopejs/interface-dms/notifications/builder |
SendableNotification | @antelopejs/interface-dms/notifications/sendable |
NotificationData is the shape the builder assembles — icon, title, description and subject, plus the optional linkTo and params. Naming it is useful when you carry notification content around before building it:
import { Notification } from "@antelopejs/interface-dms/notifications";
import type { NotificationData } from "@antelopejs/interface-dms/notifications/types";
import type { SendableNotification } from "@antelopejs/interface-dms/notifications/sendable";
function fromData(data: NotificationData): SendableNotification {
const builder = Notification()
.icon(data.icon)
.title(data.title)
.description(data.description)
.subject(data.subject);
if (data.linkTo) {
builder.linkTo(data.linkTo);
}
if (data.params) {
builder.params(data.params);
}
return builder.build();
}