Supercommerce API Docs
Admin API

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 whose status reflects 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 groupPermission
POST /admin/notifications/broadcasts, POST /admin/notifications/broadcasts/:id/cancelnotifications: broadcast
GET /admin/notifications/broadcasts, GET /admin/notifications/broadcasts/:id, GET /admin/notifications/lognotifications: view

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
500INTERNAL_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; // deliveries (users × channels, plus devices for install-base push); null until the fan-out finishes
  sentCount: number;
  failedCount: number;           // provider errors only
  skippedCount: number;          // not attempted: opted out, no device, suppressed email, device removed
  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_app content's category, default marketing) is checked; opted-out channels are skipped (logged skipped, reason disabled_by_preference). Marketing push and in-app are on by default; marketing email is opt-in.
  • Email suppression — any address on the email_suppression list (from the one-click unsubscribe link injected into every campaign email) is skipped (logged skipped, reason email_suppressed).
  • Block/legacy-HTML email bodies also carry a signed unsubscribe link (footer + {{unsubscribe_url}} variable).

Push to all_customers goes to the install base. It sends one push per customer-app device in user_device that existed when the broadcast started — including devices nobody has signed in on (tokens migrated from the previous platform, or apps that registered while signed out). A device linked to a customer honours that customer's preference and gets {{name}} filled in; an unlinked device (or one linked to a guest account) has no preferences and gets the placeholder blanked. Other channels, and push to every other audience, still go per recipient to the devices linked to each user.

The install-base send is one job per broadcast on its own queue (notification.broadcast-push), so it never blocks the per-recipient queue. The job walks the devices 500 at a time and hands each page to FCM as one sendEach batch (the Firebase Admin SDK sends the 500 individually-addressed messages over one HTTP/2 connection; FCM v1 has no multi-token endpoint). It saves its position after every page, so a retried or stalled job resumes from the last finished page, and it re-checks the status between pages, so a cancel takes effect within one page. totalRecipients starts as the device count at start time and is replaced with the exact number once the last page is sent.

Counters. Every job ends as exactly one of sentCount / failedCount / skippedCount. failedCount is provider errors only (a push counts as sent when FCM accepts at least one token); a recipient with no device, an opt-out, a suppressed address or a device removed mid-run is skipped. The broadcast finishes sent when nothing failed (skips included), failed when nothing was sent but something failed, else partial.

The per-recipient fan-out is resumable: the audience is paged and enqueued via addBulk with a deterministic job id per (broadcastId, userId, channel), so a worker crash mid-fan-out resumes (BullMQ dedups on the job id) instead of double-sending or stranding. totalRecipients is set once the fan-out has counted every job, so the broadcast can't be marked finished while jobs are still being enqueued. A crash mid-page of the install-base push re-sends that page (up to 500 devices may get it twice, and the counters over-count it); its final total re-settles the status.

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

typeExtra fieldsBehaviour
"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:

ChannelShapeNotes
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

FieldTypeNotes
scheduleForISO datetime?Send-now if omitted. Past timestamps are rejected by the service

Response 201 — BroadcastResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody 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

NameTypeDefaultNotes
pageint1>= 1
limitint501..200
statusstring?—Filter by status string

Response 200 — paginated envelope of BroadcastResponse[].


GET /admin/notifications/broadcasts/:id — Broadcast detail

Required permission: notifications: view.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown 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

StatusCodeWhen
404NOT_FOUNDUnknown 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

NameTypeDefaultNotes
pageint1>= 1
limitint501..200
recipientUserIdstring?—Filter to one recipient
eventTypestring?—e.g. "order.placed", "broadcast"
broadcastIdstring?—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 + PathPurpose
GET /admin/notifications/feedPaginated feed
GET /admin/notifications/feed/unread-count{ unread, unseen }
GET /admin/notifications/feed/streamSSE live feed
POST /admin/notifications/feed/:id/read · /read-all · /seen · /:id/archiveState transitions
GET · PUT /admin/notifications/feed/preferencesPreference 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):

MethodPathPermissionPurpose
GET/admin/{app,push}-notifications/templatesnotificationTemplate:viewList templates (effective content + enabled + hasOverride + available variables).
GET/admin/{app,push}-notifications/templates/:eventType/:rolenotificationTemplate:viewOne template.
PUT/admin/{app,push}-notifications/templates/:eventType/:rolenotificationTemplate:updateUpsert override { enabled, title, body, actionUrl } (blank field → default).
POST/admin/{app,push}-notifications/templates/:eventType/:role/previewnotificationTemplate:viewRender 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.


  • admin-rbac — gates the broadcast/log endpoints via notifications:view / notifications:broadcast; the admin's own feed is ungated. See admin-rbac.md.
  • notifications-email-mailer / notifications-push-fcm — channel plugins consumed by both broadcasts and per-event triggers; the log row's provider reflects which plugin handled the send. The in-app channel is built in to @sc/notifications core.
  • order, vendor, reviews — emit domain events that the notifications module subscribes to and turns into log entries + feed rows.

On this page