Notifications
BusinessDash's notification system lets customers control their communication preferences — which channels they use and which events they receive notifications for.
const prefs = await client.notifications.getPreferences()
// → { email, sms, push, digest, categories }
await client.notifications.updatePreferences({
email: true,
sms: false,
digest: 'weekly',
categories: { marketing: false, billing: true, updates: true },
})
Channel verification
Before sending to a new email or phone number, verify the channel:
// Start verification (sends code)
await client.notifications.startVerification({
channel: 'email',
address: 'jane@example.com',
})
// Confirm with the code
await client.notifications.confirmVerification({
channel: 'email',
address: 'jane@example.com',
code: '123456',
})
React components
import { VerifyEmailFlow, VerifyEmailConfirm, VerifyPhoneFlow }
from '@businessdash/sdk/react'
<VerifyEmailFlow email="jane@example.com" onVerified={() => {}} />
<VerifyPhoneFlow phone="+15551234567" onVerified={() => {}} />
One handler, every notification
Wiring each event type into your own toast component by hand means finding each one, polling it, diffing it, and writing a handler — and doing it again every time the platform adds an event. The notification feed inverts that: attach one handler and receive everything, already normalised.
import { createNotificationFeed } from '@businessdash/sdk/notifications'
import { toast } from 'sonner' // or your own component
const feed = createNotificationFeed({ portal })
feed.subscribe((n) => toast(n.title, { description: n.body }))
feed.start()
That is the whole integration. Nothing in your code names an event type, so new platform events arrive through the same handler with no change on your side.
The SDK deliberately ships no toast UI. A notification component is one of
the most branded things on a site, and one that arrives with its own styling is
one your designer has to fight. You get normalised data; the rendering stays
yours. toToast() flattens a notification into the title / description /
variant / action shape that sonner, react-hot-toast, vue-toastification
and shadcn's useToast all accept:
feed.subscribe((n) => {
const { title, description, variant, action } = toToast(n)
myToast({ title, description, variant, action })
})
Every notification has the same shape whatever it came from:
| Field | What it is |
|---|---|
id | Stable across sources — two deliveries of one event share it |
source | push, poll, or local |
category | transactional, messages, marketing, … |
kind | A dotted event name: order.shipped, invoice.paid |
title / body | Ready to render; always populated |
url | Where it points, if anywhere — render as the toast's action |
severity | info | success | warning | error |
occurredAt | ISO 8601, when the event happened |
orgId | Which company it came from |
data | Everything event-specific: order id, ETA, and so on |
Where notifications come from
Two sources feed the one stream:
- Push — the service worker relays what it received to any open tab. Instant, but only while a tab is open. When the site is closed the system notification is the delivery.
- Polling — watches the signed-in customer's portal and raises a notification when something changes. Works without push permission, which matters because most visitors never grant it.
The same event routinely arrives from both — push instantly, the poll a minute
later. The feed deduplicates before any handler sees it, so a customer is
never told twice. Pass pollIntervalMs to change the cadence (floor: 15s).
You can add your own source — a websocket, an SSE stream, your own backend — and it joins the same deduplicated stream:
createNotificationFeed({
portal,
sources: [(emit) => {
const socket = new WebSocket(url)
socket.onmessage = (e) => emit({ title: e.data })
return () => socket.close()
}],
})
feed.emit({ title: 'Saved' }) routes your app's own messages through the same
component, so you keep one notification surface rather than two.
Handlers are not replayed on subscribe: a toast is a moment, and replaying a
morning's worth on every mount fires a stack of stale toasts. Use
feed.recent() to render a bell or an inbox, where showing history is the point.
Browser (push) notifications
Push lets you reach someone when your site is closed. Two things shape the setup, and both are worth knowing before you start.
A subscription belongs to an origin. The service worker has to be served from your domain — a worker on someone else's, including ours, cannot receive your visitors' notifications. Copy the one the SDK ships to the root of your site:
cp node_modules/@businessdash/sdk/assets/bd-sw.js public/bd-sw.js
It must sit at the root. A worker at /assets/bd-sw.js can only control
/assets/ unless the response carries a Service-Worker-Allowed header, which
is more setup than moving one file.
The signing key is not per-org. One platform VAPID pair serves every company, so there is nothing for an org to register or configure.
// Call this from a click — see below.
const result = await portal.enablePush({ label: 'Work laptop' })
if (!result.ok) {
// 'denied' | 'dismissed' | 'ios-needs-home-screen' | 'not-configured' | …
showMessage(result.message)
}
enablePush() does the whole dance: reads the key, registers the worker, asks
permission, records the subscription, and rolls the local subscription back if
the server call fails — so you never end up with a browser that looks subscribed
and receives nothing.
Call it from a user gesture, never on page load. A browser asks for notification permission once per origin, and a refusal is sticky — the visitor has to dig through site settings to undo it. Prompting before someone knows what they're agreeing to spends the only chance you get on a reflexive "no".
The failure reasons are returned rather than thrown, because they need different
copy. denied can't be retried from your page at all; dismissed can be asked
again tomorrow; ios-needs-home-screen is fixed by the visitor in about ten
seconds if you tell them how.
iOS
iOS 16.4+ supports standard Web Push, but only once the site has been added to
the Home Screen. A plain Safari tab on iPhone cannot receive push at all,
whatever you configure. enablePush() reports that case specifically so you can
explain it instead of showing a button that silently fails.
Safari 16+ on macOS and iOS use the same VAPID flow as every other browser. Apple's older Website Push ID registration applies to macOS Safari 15 and earlier, and isn't used here.
Managing devices
await portal.loadPushConfig()
const { push } = portal.snapshot()
// push.devices → [{ id, label, categories, lastSeenAt, … }]
// Narrow what one device receives. `[]` keeps the subscription warm.
await portal.updatePushDevice({ id, categories: ['transactional', 'messages'] })
// Sign a browser you no longer have out
await portal.forgetPushDevice(id)
// Turn push off on THIS browser (tears down the local subscription too)
await portal.disablePush()
A device is per person, not per company: a push subscription identifies a
browser, and someone who deals with three companies on the platform has one
browser. Which company may push to it lives in that company's notification
preference matrix, which already has a push channel — so muting one company
never mutes the others.