Add Notifications to Your App
Your backend decides something happened. A bell lights up in the user's browser a moment later, and an email arrives if they never came to look. This guide wires that up end to end.
Drift-tested
The SDK surface on this page is verified in CI against the shipped sources — be-integrations and fe-integrations. If a symbol here stops existing, or the SDK grows one no guide mentions, the build fails. See Keeping this page honest.
Known limits
Comms ships with seven documented weaknesses that were found and consciously accepted — among them a revoked membership keeping its websocket for up to 12 hours. Read Communications — Accepted Risks before you put this in front of users.
The shape of it
your backend ──emit──▶ Manager ──┬── websocket ──▶ browser (bell lights up)
└── outbox ─────▶ email (if unread, and the
user's window is open)Three things are worth knowing before you write any code:
- Your backend never talks to the browser. It calls the Manager with a machine credential; the Manager fans out. There is no socket for you to hold.
- The browser holds no credential. The comms socket is same-origin and the proxy injects the identity. A token in frontend code would be a bug, not a shortcut.
- Notifications are durable, and that is the difference between this guide and Live Data Updates. A notification is a row: it survives a closed tab, it has a read state, and it can fall back to email. A live-data hint is not and does not.
1. Turn comms on for the tenant
Comms is opt-in per (tenant, application) and defaults to off, so a fresh app emits nothing until you say otherwise:
pfy application comms-config set --app <app-id> --tenant-id <tenant-id> --enabledIt rides the normal configuration cascade — project → tenant → tenant-and-application — so --layer project sets a default every tenant inherits, and the per-tenant leaf overrides it:
pfy application comms-config get --app <app-id> --tenant-id <tenant-id>Only the flags you actually type are written. An omitted flag means "this layer has no opinion" and the cascade falls through; --enabled=false means "off, and I mean it". That distinction is load-bearing — do not set fields you do not care about.
Emitting to a tenant that has not opted in returns 403 comms_disabled.
2. Emit from your backend
Node / Express
import { createCommsClient } from "@productifyfw/node-express";
const comms = createCommsClient({
managerUrl: process.env.MANAGER_URL,
machineUserToken: process.env.MACHINE_USER_TOKEN,
applicationId: process.env.APPLICATION_ID,
});
await comms.notifications.emit(
{ userIds: [order.customerId], tenantId: order.tenantId },
{
kind: "order.shipped",
title: "Your order shipped",
body: { orderId: order.id, carrier: "DHL" },
entityUrl: `/orders/${order.id}`,
priority: "normal",
},
);createCommsClient takes managerUrl, machineUserToken and applicationId. The credential is environment-scoped: one backend registers per (application, environment) and serves every tenant in it, which is why tenantId travels with each call rather than being baked into the client.
Go
import productify "github.com/ProductifyFW/be-integrations/go"
comms := productify.CommsClient{
ManagerURL: os.Getenv("MANAGER_URL"),
MachineUserToken: os.Getenv("MACHINE_USER_TOKEN"),
ApplicationID: os.Getenv("APPLICATION_ID"),
}
res, err := comms.Emit(ctx, productify.EmitRequest{
TenantID: order.TenantID,
Kind: "order.shipped",
Title: "Your order shipped",
Body: map[string]any{"orderId": order.ID},
EntityURL: "/orders/" + order.ID,
Priority: productify.PriorityNormal,
Recipients: productify.UserRecipients(order.CustomerID),
})CommsClient is composed as a struct literal — there is no constructor. Emit returns an EmitResult (NotificationID, RequestID, RecipientCount); the sibling Publish method and its PublishRequest belong to Live Data Updates.
3. Choose recipients
Exactly one of two forms, never both:
| Form | Node | Go |
|---|---|---|
| Named users | { userIds: [...], tenantId } | productify.UserRecipients(ids...) |
| Everyone in the tenant | { allTenantMembers: true, tenantId } | productify.AllTenantMembers() |
Both arms carry tenantId (Go: the TenantID field on the request, with the Recipients value naming only the form). Sending both forms, or neither, is a 400 — the SDKs refuse it locally before the request leaves your process.
User ids you name are filtered against tenant membership, and non-members are dropped silently. A request naming one non-member still returns 202 with recipientCount: 0. This is deliberate: if emit returned 404 for a stranger's id, it would be a membership oracle — anyone with a backend credential could enumerate who belongs to a tenant. Check recipientCount if you need to know whether anyone was actually reached; do not read 202 as "someone got it".
4. Receive it in the browser
If you use the shipped pilet, you are already done — add @productifyfw/comms-pilet to your feed and render a header-actions extension slot in your shell. It mounts a bell and an /inbox page, and falls back to polling where websockets are unavailable.
To build your own UI, use the core client:
import { getCommsClient } from "@productifyfw/core";
const comms = getCommsClient();
comms.onNotification((item) => {
toast(item.title, { href: item.entity_url });
});
comms.onUnread((unread) => {
bell.classList.toggle("has-unread", unread);
});getCommsClient returns a process-wide client backed by one socket per browser, not per tab: a SharedWorker owns the connection and fans out to tabs over message ports, replaying its cache (unread flag, last ten items, active subscriptions) to whichever tab attaches next. getSnapshot() reads that cache synchronously — useful for first paint, before any callback has fired. Where SharedWorker is unavailable the client transparently falls back to a per-tab socket; usingSharedWorker tells you which you got.
Every registration returns its own unsubscribe function. close() tears the client down; you rarely need it outside tests, because a tenant switch already handles its own teardown (see Live Data Updates).
Notification payloads arrive in snake_case, matching the wire exactly: id, kind, title, body, entity_url, priority, created_at, read_at, seen_at.
entity_url is a link, not an instruction
The browser decides what to do with it. Never put a URL in body and expect the client to fetch it — see the payload rules, which apply to notification bodies too.
5. Email fallback and quiet hours
If a notification is still unread when the outbox worker next runs, and the tenant has email enabled, it is sent as mail. Two things hold it back:
- The grace window (
digest.grace_minutes, default 5) — breathing room for in-app delivery to claim it first. A user who reads the notification inside the window never gets the email. - The user's delivery preferences — a per-channel set of sendable windows evaluated in the user's own timezone, plus a
modethat can switch the channel off entirely.
Outside a sendable window the email is deferred, never dropped. The row stays in the outbox and goes out on a later pass once the window opens. Delivery is retried for up to seven days, then given up on.
Users manage their own windows through the delivery-preferences GraphQL surface; operators set the sender identity (--email-from, --email-subject-prefix) with the same comms-config command as above. None of the email configuration is ever visible to a browser — the only communications value the frontend receives is { "enabled": true }.
Errors
Both SDKs raise a typed error rather than a bare HTTP failure. In Node that is CommsError, carrying a code:
| Code | Meaning | What to do |
|---|---|---|
comms_disabled | comms is off for this (tenant, app) | operator action — enable it; do not retry |
not_authorized | the app or tenant is not reachable with this credential | fix the ids or the credential; do not retry |
unauthorized | the machine token is missing, wrong, or disabled | rotate/repair the credential |
rate_limited | too many emits for this application | back off and retry |
Go mirrors these as sentinel errors (ErrCommsDisabled, ErrCommsNotAuthorized, and friends) wrapped by a CommsError value, so errors.Is works as you expect.
The client-side guardrails fire before any request: kind must match ^[a-z0-9._-]{1,64}$, title must be 1–200 characters, body must serialize under 16 KB, and empty recipients are refused outright.
Limits
| Thing | Limit |
|---|---|
kind | ^[a-z0-9._-]{1,64}$ |
title | 1–200 characters |
body | ≤ 16 KB serialized |
priority | normal or high |
| Inbox page size | 20 default, 100 max |
Keeping this page honest
docs/scripts/check-comms-drift.mjs parses the comms surface out of be-integrations/packages/node-express/src/types.ts, be-integrations/go/comms.go and fe-integrations/packages/core/src/comms-client.ts, and fails when this page and Live Data Updates fall out of step with them — in either direction. Run it locally with sibling checkouts present:
node scripts/check-comms-drift.mjsThe behaviour described here is separately exercised end to end by deployments/e2e/, which drives a real Manager, a real websocket and a real SMTP sink. Note what that harness does not cover — the proxy hop — before treating a green run as full coverage.