@colixsystems/notifications-client
v0.4.0
Published
Scoped client for the AppStudio widget send-notification API. Used by widgets through the injected WidgetContext.notifications.
Readme
@colixsystems/notifications-client
Scoped client for the AppStudio widget send-notification API (sc-890). It is a communication-plane sibling of @colixsystems/payments-client, @colixsystems/datastore-client, @colixsystems/assets-client, and @colixsystems/directory-client. It is a standalone fetch-based client you instantiate with createNotificationsClient({ baseUrl, getToken, getTenantId }).
Two surfaces, one package. This client serves two callers:
- External / server-side integrations instantiate it directly with an API key and call
sendbelow.- The widget runtime — the Player and exported Expo app instantiate the same package and inject it into
WidgetContext.notifications({ send, mutes }). Widgets never import this package; they call the SDK hooksuseSendNotification()anduseRecordMute()from@colixsystems/widget-sdk, which readctx.notifications. Both surfaces speak the identical snake_case REST contract.
Status
v0.4.0 — createNotificationsClient({ platform }) takes the client the host runs on ("web", "ios" or "android") and names it when the live stream subscribes, so a workspace that records user activity can tell which apps its users use. Optional and additive.
v0.3.0 — adds mutes (get / set / clear / list): the signed-in app user's mute of one record's notifications, and the list of the records they have muted on a table.
Wire format — snake_case, no transform
The wire format is snake_case in both directions (REQ-GEN-09) and that is the client contract. The SDK does no camelCase↔snake_case transform. Request bodies are sent snake_case verbatim ({ recipient_user_id, title, body, link?, payload? }); response objects are returned snake_case verbatim ({ id, tenant_id, user_id, title, body, link, payload, read_at, created_at }). The only caller-facing camelCase is the JS method name (send) and the factory option names.
Public API
import {
createNotificationsClient,
NotificationError,
NotFoundError,
ForbiddenError,
ValidationError,
RateLimitedError,
ServerError,
} from "@colixsystems/notifications-client";
const client = createNotificationsClient({
baseUrl: "https://api.appstudio.io/api/v1",
getToken: () => "Bearer ...",
getTenantId: () => "tenant_abc",
// Optional: per-request extra headers (e.g. a per-widget scope token). Called
// with { namespace: "notifications", operation } and merged into the request.
getRequestHeaders: ({ namespace, operation }) => ({ "X-Widget-Scopes": "..." }),
// fetchImpl defaults to globalThis.fetch
});
// Send an in-app notification to one app user in the tenant. Rejects with a
// NotificationError subclass on a 4xx/5xx (INVALID_TITLE / INVALID_BODY /
// INVALID_RECIPIENT / INVALID_PAYLOAD → ValidationError; RECIPIENT_NOT_FOUND →
// NotFoundError; RATE_LIMITED → RateLimitedError; 401/403 → ForbiddenError).
const notification = await client.send({
recipient_user_id: "u_123",
title: "New comment",
body: "Alex replied to your post.",
link: "/posts/42",
payload: { post_id: "42" },
});
// → { id, tenant_id, user_id, title, body, link, payload, read_at, created_at }Surface
| Method | Transport | Returns |
| --- | --- | --- |
| send(body) | POST /notifications/send | Notification (snake_case row) |
| mutes.get({ table_id, record_id }) | GET /notifications/mutes | { muted, muted_until } |
| mutes.set({ table_id, record_id, muted_until }) | PUT /notifications/mutes | { muted, muted_until } |
| mutes.clear({ table_id, record_id }) | DELETE /notifications/mutes | undefined (204) |
| mutes.list({ table_id, limit?, offset? }) | GET /notifications/muted-records | { data: [{ record_id, muted_until }], meta } |
| subscribe(handlers, options?) | WS /datastore/ws | () => void (unsubscribe) |
send body (snake_case verbatim): { recipient_user_id, title, body, link?, payload? }.
mutes
The signed-in app user's mute of the notifications one datastore record produces for them, for example a chat conversation. While a mute is active, a server action's notifications.notifyRecordSubjects on that record sends the user nothing: no inbox row, no push, no email.
await notifications.mutes.set({ table_id, record_id, muted_until: "2030-01-01T09:00:00Z" });
await notifications.mutes.set({ table_id, record_id, muted_until: null }); // until unmuted
const { muted, muted_until } = await notifications.mutes.get({ table_id, record_id });
await notifications.mutes.clear({ table_id, record_id });
const { data, meta } = await notifications.mutes.list({ table_id }); // active mutes, by record id- Your own mutes only. The user comes from the token, never an argument.
- Read access required. A record the user cannot read rejects with
NotFoundError(err.details.code === "RECORD_NOT_FOUND"), exactly as for a record that does not exist. muted_untilis an ISO 8601 time with a time zone, ornullfor until unmuted. A time that is not in the future rejects withValidationError(INVALID_MUTED_UNTIL); ids that are not UUIDs reject withINVALID_RECORD_REF.- A lapsed mute reads
{ muted: false, muted_until: null }. listreturns only the user's own mutes that are still active, ordered by record id, so a list screen shows every muted row in one read.limitdefaults to 100 and is clamped to 200; atable_idthat is not a UUID rejects withINVALID_TABLE_ID.
subscribe(handlers, options?)
Streams the signed-in recipient's own new notifications over the shared
/datastore/ws socket, so a host does not have to poll the inbox. It is host
chrome (the Player's notification bell), not a widget capability — ctx.notifications
inside a widget exposes send and mutes only.
const stop = notifications.subscribe({
onCreated: (n) => { /* same snake_case row GET /notifications returns */ },
onStatus: (s) => { /* "connecting" | "live" | "reconnecting" | "fallback" */ },
});
// …later
stop();- Recipient-scoped by the token, never by an argument. The server attaches the
socket to the inbox of the app user its verified
APP_USERclaim names; there is no way to ask for someone else's. - Poll while the status is not
"live". A blocked upgrade, a refused subscribe, or a dropped socket reports"fallback", and reconnects run with capped exponential backoff in the background. - Delivery is at-least-once — the same frame can arrive twice, so dedupe on
notification.idbefore counting it. - No unread count rides the wire; a caller increments locally and reconciles with
the
meta.unread_counton its nextGET /notifications.
| Option | Type | Notes |
| --- | --- | --- |
| fallbackAfterMs | number | How long the first subscribe may take before "fallback" is reported. Default 3000. |
Factory options
| Option | Type | Notes |
| --- | --- | --- |
| baseUrl | string | Required. API root, e.g. https://api.appstudio.io/api/v1. |
| getToken | () => string \| Promise<string> | Required. The Authorization header value; "Bearer " prefix added if missing. |
| getTenantId | () => string \| Promise<string> | Required. The x-tenant-id header value. |
| getRequestHeaders | ({ namespace, operation }) => object \| Promise<object> | Optional. Extra headers merged per request. |
| fetchImpl | typeof fetch | Optional. Defaults to globalThis.fetch. |
| webSocketImpl | typeof WebSocket | Optional. Defaults to globalThis.WebSocket (present in browsers and React Native). Absent → subscribe reports "fallback" immediately. |
Transport
- Idempotent GETs retried 3× with exponential backoff (200/400/800 ms); non-idempotent verbs are not retried.
sendis a POST, so it is not auto-retried on a network failure — a retried send could deliver a duplicate notification. - 10 s default per-call timeout via
AbortController. - Typed
NotificationErrorhierarchy (NotFoundError,ForbiddenError,ValidationError,RateLimitedError,ServerError) pluserrorFromResponse.
Dependencies
None. The client uses only platform fetch, AbortController, and (for subscribe) WebSocket — all available in modern browsers, Node 18+, and React Native.
