Supercommerce API Docs
Store API

Notifications Module — Storefront

HTTP surface for the customer app: register/unregister FCM device tokens, the in-app notification feed (bell/inbox with read/seen state, SSE live stream, images), the per-user notification preference matrix, and the public token-gated marketing-email unsubscribe. The in-app channel mirrors push.

HTTP surface for the customer mobile app to register and unregister its FCM device token for push notifications. Customer devices are tagged appKind: "customer" (distinct from the vendor app's appKind: "vendor") so broadcasts can target the right audience without leaking notifications across apps.

Source: api-modules/notifications/src/controllers/store-device.controller.ts.

Admin broadcast composition and log inspection live in docs/separated/admin/notifications.md. The vendor device-register surface mirrors this one and lives in docs/separated/vendor/notifications.md.


Conventions

Authentication

EndpointAuth
POST /store/devicesrequired (customer session)
DELETE /store/devicesrequired (customer session)

Device registration is tied to the session user via session.user.id — there's no "register a device for someone else" surface.

Response envelope

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Domain types

DeviceResponse

type DeviceResponse = {
  id: string;
  platform: "android" | "ios";
  appKind: "customer";                   // always "customer" on this surface
  lastSeenAt: string;                    // ISOrefreshed on every re-register
};

Endpoints

POST /store/devices — Register a customer device

Register or refresh an FCM registration token for the calling customer. Idempotent on (userId, token) — re-registering the same token refreshes lastSeenAt instead of creating a duplicate row. Stale tokens (where the FCM SDK has rotated the registration) are naturally swept by the next register call.

Body

{
  "platform": "android",                 // or "ios"
  "token": "fXyZ...firebase-registration-token..."
}
FieldTypeConstraints
platformenumandroid / ios
tokenstringtrimmed, 10..4096 chars

Response 201DeviceResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORToken too short / wrong platform value
401UNAUTHORIZEDNo customer session

DELETE /store/devices — Unregister a device

Unregister an FCM token. Idempotent — unregistering an unknown or already-unregistered token still returns 200; the response body is { "ok": true }. Use this on user-initiated sign-out so the device stops receiving pushes.

Body

{ "token": "fXyZ...firebase-registration-token..." }
FieldTypeConstraints
tokenstringtrimmed, 1..4096 chars

Response 200

{ "data": { "ok": true }, "message": "Success", "statusCode": 200 }

Errors

StatusCodeWhen
400VALIDATION_ERROREmpty token
401UNAUTHORIZEDNo customer session

In-app notification feed

A persisted, per-user notification centre (the "bell"). The in-app channel mirrors push: whenever a push is sent to the customer, the same content is also written here as a durable, readable row (push is ephemeral; the feed is the record). Rows carry seen/read state and optional images.

Source: api-modules/notifications/src/controllers/store-notification.controller.ts. Feed rows are appKind: "customer".

NotificationFeedItem

type NotificationFeedItem = {
  id: string;
  category: "orders" | "returns" | "payouts" | "affiliate" | "account" | "marketing" | "system";
  eventType: string;                 // source domain event, e.g. "order.placed"
  title: string;
  body: string;
  actionUrl: string | null;          // deep-link target
  icon: string | null;
  images: { url: string; alt: string | null }[];
  data: Record<string, string>;      // deeplink ids, analytics tags
  seen: boolean;
  read: boolean;
  createdAt: string;                 // ISO
};

GET /store/notifications — Paginated feed (newest first)

Offset-paginated (buildPaginatedResponse); archived rows excluded. Query: shared querySchema (limit 1..500 default 100, offset) plus unreadOnly (boolean) and category. Response 200{ data: NotificationFeedItem[], metadata: { total, limit, offset, hasMore } }.

GET /store/notifications/unread-count — Badge counts

Response 200{ unread: number, unseen: number }. unseen drives the bell dot; unread the "N unread" label.

GET /store/notifications/stream — Live feed (SSE)

Server-Sent Events for the signed-in customer. Emits { type: "ready", unread, unseen } on connect, then { type: "notification", ... } per new feed row, plus { type: "heartbeat" } every 30s. @SkipResponseWrap — raw SSE, not the JSON envelope.

POST /store/notifications/:id/read — Mark one read

Returns { ok: true }. 404 when the id isn't the caller's (ownership-scoped — a foreign id is indistinguishable from a missing one).

POST /store/notifications/read-all — Mark all read

Returns { ok: true, updated: <count> }.

POST /store/notifications/seen — Clear the bell badge

Marks all rows seen (badge → 0) without marking them read. Returns { ok: true, updated: <count> }.

POST /store/notifications/:id/archive — Dismiss

Removes the row from the feed. Returns { ok: true }. 404 if not owned.


Notification preferences

A per-user opt-in matrix: for each category × channel (in_app / email / push) the user chooses on/off. Absent entries fall back to the platform default policy (transactional categories on everywhere; marketing off for email/push; account/system keep push quiet). The in-app copy defaults to mirror push but is mutable independently.

GET /store/notifications/preferences — The matrix

Response 200 — array of { category, channels: { in_app: boolean; email: boolean; push: boolean } }, one row per category, merged over defaults.

PUT /store/notifications/preferences — Update overrides

Body: { entries: { category, channel, enabled }[] } (1..50 entries). Upserts the given cells; returns the full recomputed matrix.


Marketing email unsubscribe (public)

One-click opt-out for marketing/broadcast emails. Transactional (order/auth) email is exempt and never consults the suppression list. The unsubscribe link is injected into every campaign/broadcast email (as a footer link and the {{unsubscribe_url}} template variable).

Source: api-modules/notifications/src/controllers/store-unsubscribe.controller.ts.

  • No session/auth guard — the signed token in the link is the credential. It is an opaque base64url(email).HMAC-SHA256(email) value (server secret: UNSUBSCRIBE_SECRET, falling back to BETTER_AUTH_SECRET). The endpoint verifies the signature in constant time; a valid token records a suppression row (idempotent upsert on the lower-cased email) in email_suppression. The broadcast worker then skips that address for future marketing email.
  • Both verbs behave identically; GET supports one-click unsubscribe from the mail client, POST mirrors it for form submissions.

GET /store/notifications/unsubscribe?token=<token> — Unsubscribe

Query: token (required, the signed token from the email link).

Response 200{ "unsubscribed": true }. 400 (BAD_REQUEST) when the token is missing, malformed, or its signature doesn't verify.

POST /store/notifications/unsubscribe?token=<token> — Unsubscribe

Same query, response, and errors as the GET form.


  • notifications-push-fcm — the FCM transport that delivers the push whose content the in-app feed mirrors.
  • auth — owns the session whose user.id keys the device, feed, and preference rows.

On this page