Notifications Module — Admin
HTTP surface for the admin notification operations: composing and sending broadcasts (one-shot email and/or push to a targeted audience) and inspecting the notification log…
HTTP surface for the admin notification operations: composing and sending broadcasts (one-shot email and/or push to a targeted audience) and inspecting the notification log (per-message audit of every send across every channel and trigger).
Source:
api-modules/notifications/src/controllers/admin-broadcast.controller.ts,api-modules/notifications/src/controllers/admin-notification-log.controller.ts.Channels are plugged in via the per-channel modules (
notifications-email-mailer,notifications-push-fcm). Broadcast send / schedule are queue-backed — the controller returns immediately with a row whosestatusreflects scheduled/in-progress; per-recipient jobs run async.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching notifications:* permission.
| Endpoint group | Permission |
|---|---|
POST /admin/notifications/broadcasts, POST /admin/notifications/broadcasts/:id/cancel | notifications: broadcast |
GET /admin/notifications/broadcasts, GET /admin/notifications/broadcasts/:id, GET /admin/notifications/log | notifications: view |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Domain types
BroadcastResponse
type BroadcastResponse = {
id: string;
status: string; // service-defined: "scheduled", "in_progress", "completed", "cancelled", "failed"
audienceType: string; // "all_customers" | "all_vendors" | "user_ids"
channels: string[]; // subset of ["email", "push"]
scheduledFor: string | null; // ISO (null = send-now)
totalRecipients: number | null; // null until the audience is materialised
sentCount: number;
failedCount: number;
createdAt: string; // ISO
};NotificationLogResponse
type NotificationLogResponse = {
id: string;
recipientUserId: string | null;
recipient: { id: string; name: string; email: string } | null;
channel: string; // "email" | "push" | ...
provider: string; // e.g. "mailer", "fcm"
eventType: string; // domain event that triggered the send (or "broadcast")
broadcastId: string | null; // populated when triggered by a broadcast
subject: string | null; // for email
status: string; // "sent" | "failed" | provider-specific
error: string | null; // free-form provider error message
sentAt: string; // ISO
};Broadcasts
Base path: /admin/notifications/broadcasts.
POST /admin/notifications/broadcasts — Create + send (or schedule) a broadcast
Required permission: notifications: broadcast. The controller enqueues the broadcast and returns the persisted row immediately. If scheduleFor is omitted, the broadcast starts dispatching at once; if present, it is queued as a delayed job.
Dispatch honours each recipient's opt-out choices, so a broadcast never reaches someone who muted it:
- Per-category channel preference — before every channel send, the recipient's preference for the broadcast's category (the
in_appcontent'scategory, defaultmarketing) is checked; opted-out channels are skipped (loggedskipped, reasondisabled_by_preference). Marketing is off by default for email/push. - Email suppression — any address on the
email_suppressionlist (from the one-click unsubscribe link injected into every campaign email) is skipped (loggedskipped, reasonemail_suppressed). - Block/legacy-HTML email bodies also carry a signed unsubscribe link (footer +
{{unsubscribe_url}}variable).
The fan-out is resumable: the audience is paged and enqueued via addBulk with a deterministic per-(broadcastId, userId, channel) job id, so a worker crash mid-fan-out resumes (BullMQ dedups on the job id) instead of double-sending or stranding.
Body
{
"audience": { "type": "all_customers" },
"channels": ["email", "push"],
"content": {
"email": {
"subject": "Our biggest sale of the year",
"html": "<h1>Hi!</h1>...",
"text": "Hi! ..."
},
"push": {
"title": "Sale starts now",
"body": "Up to 50% off across the store",
"imageUrl": "https://cdn.example/banners/sale.png",
"data": { "deepLink": "/sale" }
}
},
"scheduleFor": "2026-06-01T03:00:00.000Z"
}Audience union
type | Extra fields | Behaviour |
|---|---|---|
"all_customers" | — | Every customer (non-vendor user) |
"all_vendors" | — | Every vendor-org member |
"user_ids" | userIds: string[] (1..10000) | Targeted list |
Channels + content
channels is a 1..2 subset of ["email", "push"]. The merged content block must include a sub-payload for every channel listed in channels. Channel-content shapes:
| Channel | Shape | Notes |
|---|---|---|
email | { subject (1..200), html (1..50_000), text? (max 20_000) } | subject trimmed |
push | { title (1..120), body (1..500), imageUrl? (URL, max 2048), data? Record<string,string> } | title / body trimmed |
Other fields
| Field | Type | Notes |
|---|---|---|
scheduleFor | ISO datetime? | Send-now if omitted. Past timestamps are rejected by the service |
Response 201 — BroadcastResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod, including the cross-field "content must include every listed channel" rule |
GET /admin/notifications/broadcasts — List broadcasts
Required permission: notifications: view. Newest first.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 50 | 1..200 |
status | string? | — | Filter by status string |
Response 200 — paginated envelope of BroadcastResponse[].
GET /admin/notifications/broadcasts/:id — Broadcast detail
Required permission: notifications: view.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/notifications/broadcasts/:id/cancel — Cancel
Required permission: notifications: broadcast. Semantics:
- Scheduled: removes the delayed start job and marks the row cancelled.
- In-progress: marks the row cancelled; remaining per-recipient jobs skip when popped from the queue. In-flight provider calls already running are not aborted.
- Already completed / cancelled: no-op service-side, response still 200.
Response 200 — cancelled BroadcastResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
Notification log
GET /admin/notifications/log — Audit of sends
Required permission: notifications: view. Newest first. Use to answer "did the customer get the e-mail?" from support, or to debug a stuck broadcast.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 50 | 1..200 |
recipientUserId | string? | — | Filter to one recipient |
eventType | string? | — | e.g. "order.placed", "broadcast" |
broadcastId | string? | — | All sends triggered by one broadcast |
Response 200 — paginated envelope of NotificationLogResponse[].
Admin's own in-app feed
Every admin also has a personal notification centre (the "bell") for operational alerts. This is the caller's own inbox — guarded by session only, no RBAC permission (like /profile/*). Feed rows are appKind: "admin".
Source:
api-modules/notifications/src/controllers/admin-notification-feed.controller.ts.
The NotificationFeedItem shape, SSE stream, and preference matrix are identical to the storefront surface — see store/notifications.md. Admin paths (all session-only, ungated):
| Method + Path | Purpose |
|---|---|
GET /admin/notifications/feed | Paginated feed |
GET /admin/notifications/feed/unread-count | { unread, unseen } |
GET /admin/notifications/feed/stream | SSE live feed |
POST /admin/notifications/feed/:id/read · /read-all · /seen · /:id/archive | State transitions |
GET · PUT /admin/notifications/feed/preferences | Preference matrix |
In-app channel + audiences on broadcasts
in_app is a first-class broadcast channel — an admin can send an app notification (to the notification bell) on its own, or alongside email/push. channels accepts any subset of ["email", "push", "in_app"]; each listed channel needs its content:
"content": {
"in_app": {
"title": "Weekend sale",
"body": "20% off everything until Sunday.",
"imageUrl": "https://…/banner.jpg", // optional cover
"images": ["https://…/1.jpg"], // optional gallery
"actionUrl": "/sale", // deep-link on tap
"category": "marketing" // preference-gating category (default marketing)
}
}If in_app is listed without its own content.in_app, it falls back to content.push. Recipients who muted that category in their preferences are skipped. Delivery is audited on notification_log with channel: "in_app", deduped per broadcast.
Audiences (audience.type): all_customers, all_vendors, user_ids ({ userIds: [...] }, ≤10k — also what the admin "pick recipients" picker produces), and emails ({ emails: [...] }, resolved to users; unknown addresses skipped).
Vendor dashboard URL
Vendor-facing emails link into the vendor dashboard, and an email can't use a
relative path the way the in-app feed can. Set
admin.notifications.vendor_app_url (Settings → Notifications) to that app's
origin, e.g. https://vendor.example.com. Blank is safe — affected emails
still send, without a working call-to-action button.
Notification templates (in-app & push)
Event-driven in-app and push notifications ship with code-default wording, and the operator can override each one — title, body, and action link — plus toggle whether it is sent. In-app and push are independent channels (edited separately, each with its own on/off state). Overrides are sparse: any field left blank falls back to the code default; a template with enabled: false is skipped at dispatch (logged disabled_by_operator).
Two identical endpoint families, one per channel — base admin/app-notifications/templates (in-app) and admin/push-notifications/templates (push):
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /admin/{app,push}-notifications/templates | notificationTemplate:view | List templates (effective content + enabled + hasOverride + available variables). |
| GET | /admin/{app,push}-notifications/templates/:eventType/:role | notificationTemplate:view | One template. |
| PUT | /admin/{app,push}-notifications/templates/:eventType/:role | notificationTemplate:update | Upsert override { enabled, title, body, actionUrl } (blank field → default). |
| POST | /admin/{app,push}-notifications/templates/:eventType/:role/preview | notificationTemplate:view | Render the draft against sample data → { title, body, actionUrl }. |
title, body, and actionUrl interpolate {{snake_case}} fields of the event payload (e.g. {{order_number}}, {{order_id}}; money-ish fields are formatted). actionUrl is an app-relative path opened on tap — customer links resolve in the storefront (/account/orders/{{order_id}}), vendor links in the vendor app (/orders/{{order_vendor_id}}).
back_in_stock.available|customer is in both catalogs (see Back in Stock). It reaches only subscribers who have an account — a guest subscriber has no user id and no device, so the email template is the only one that can reach them.
Each channel is a removable plugin (@sc/app-notifications, @sc/push-notifications); dropping one reverts that channel to its hardcoded template and removes its editor endpoints.
Related modules
admin-rbac— gates the broadcast/log endpoints vianotifications:view/notifications:broadcast; the admin's own feed is ungated. Seeadmin-rbac.md.notifications-email-mailer/notifications-push-fcm— channel plugins consumed by both broadcasts and per-event triggers; the log row'sproviderreflects which plugin handled the send. The in-app channel is built in to@sc/notificationscore.order,vendor,reviews— emit domain events that the notifications module subscribes to and turns into log entries + feed rows.
Meta Catalog Module — Admin
HTTP surface for syncing eligible product variants to a Meta Commerce Catalog (Facebook + Instagram Shops) via the Catalog Batch API (POST /{catalog_id}/items_batch) and the…
Order Module — Admin
HTTP surface for platform-admin oversight of orders, returns, and vendor payouts. Read every order on the platform; perform ops actions (cancel on behalf of the customer, manually…