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:

FieldWhat it is
idStable across sources — two deliveries of one event share it
sourcepush, poll, or local
categorytransactional, messages, marketing, …
kindA dotted event name: order.shipped, invoice.paid
title / bodyReady to render; always populated
urlWhere it points, if anywhere — render as the toast's action
severityinfo | success | warning | error
occurredAtISO 8601, when the event happened
orgIdWhich company it came from
dataEverything 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.