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",
"link": "/sale",
"type": "timer",
"endsAt": "2026-06-01T15:00:00.000Z",
"data": { "saleId": "123" }
}
},
"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), link? (max 2048), type? ("normal" | "timer", default "normal"), endsAt? (ISO datetime), data? Record<string,string> } | title / body trimmed. endsAt is required for timer and must fall after the send time (400 otherwise) |
Push delivery. Every broadcast push carries type ("normal" / "timer") in its data payload; link is sent as both deeplink and redirectUrl. A timer push also carries dateTime (the endsAt epoch in ms) and is delivered data-only on Android (with title / body / image in the data) so the app renders the live countdown, and as an alert on iOS / web with the time left appended to the body ("50% off - 5 hours, 34 minutes remaining"). A timer whose endsAt has passed by dispatch time (e.g. a late scheduled send) goes out as a normal push. When in_app falls back to the push content, link becomes the bell item's deep link.
Push images. imageUrl must be an absolute, publicly fetchable URL — FCM (and the app) download it, so a storage key or a private-bucket link yields no image. It is sent on every surface a client might read: notification.image, data.image (the mobile apps build their own notification from the data payload and read it there), apns.fcm_options.image, and webpush.notification.image. Every push sets mutable-content so the iOS notification service extension wakes up to fetch the attachment.
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.vendor_urls.portal_url (Settings → Vendor URLs) 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.
Previously
admin.notifications.vendor_app_url. Installs that set it there are still read as a fallback until the new field is saved.
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.
Navigation Module — Admin
HTTP surface for managing storefront navigation menus and the nested item tree inside each one. Menus are addressable by slug, read by the storefront, and this admin surface is the only writer.
Order Module — Admin
HTTP surface for platform-admin oversight of orders, returns, and vendor payouts. Read every order on the platform; create, edit and clone orders; perform ops actions (cancel, restore a cancelled order, mark paid, mark refunded, correct the delivery address, advance fulfillment, re-run courier assignment); queue background order exports; browse vendor ledgers and disburse payouts.