Push notifications
Vaultbase ships push notifications via two providers — OneSignal and Firebase Cloud Messaging — exposed behind a single provider-agnostic trigger:
await ctx.helpers.notify(userId, { title: "New message", body: "Alice sent you a reply", data: { type: "message", id: "m_123" },});The hook code never names a provider. Operators flip providers in
Settings → Notifications, and the dispatcher fans out to every
enabled one via the built-in _notify queue (with retry / backoff /
dead-letter), inserts an inbox row that drives the in-app UI via
realtime, and — for FCM — disables tokens the device side reports as
gone.
The two providers in one sentence each
Section titled “The two providers in one sentence each”| Provider | Identifier | Auth | Vaultbase stores tokens? |
|---|---|---|---|
| OneSignal | external_id (= vaultbase user id) | Authorization: Basic <REST API Key> | No — OneSignal owns the device layer |
| FCM | raw FCM registration token per device | OAuth2 access token (RS256 from service-account.json) | Yes — device_tokens collection |
The asymmetry matters: with OneSignal your client SDK calls
OneSignal.login(vaultbaseUserId) once and the device layer is theirs.
With FCM your client posts the FCM token to vaultbase, which stores it
and POSTs per-device.
Operator setup (one-time, per provider)
Section titled “Operator setup (one-time, per provider)”OneSignal
Section titled “OneSignal”- Sign up at onesignal.com, create an App, configure platforms
(OneSignal hosts your APNs
.p8and FCM Service Account internally). - Copy App ID and REST API Key from Settings → Keys & IDs.
- In vaultbase admin → Settings → Notifications → OneSignal: paste both, toggle Enabled, click Save.
- Click Test connection — vaultbase hits
GET /apps/<id>with the key; ✓ confirms the credentials are valid.
- In Firebase console → Project Settings → Service Accounts → Generate new private key. Save the downloaded JSON.
- In vaultbase admin → Settings → Notifications → FCM: paste the full
JSON contents into Service Account, optionally fill Project ID
(defaults to
project_idfrom the JSON). - Toggle Enabled, click Save.
- Click Test connection — vaultbase mints an OAuth2 access token from the service account; ✓ confirms RS256 signing + Google accepts the credentials.
The first time any provider is enabled, vaultbase auto-creates two
system collections: notifications (the in-app inbox) and
device_tokens (FCM/APNs registration table). Idempotent — if you
re-enable, nothing rebreaks.
Settings keys
Section titled “Settings keys”Stored in vaultbase_settings. Encrypted at rest when
VAULTBASE_ENCRYPTION_KEY is set.
| Key | Notes |
|---|---|
notifications.providers.onesignal.enabled | "1" or "0" |
notifications.providers.onesignal.app_id | OneSignal App UUID |
notifications.providers.onesignal.api_key | REST API Key (encrypted) |
notifications.providers.fcm.enabled | "1" or "0" |
notifications.providers.fcm.project_id | Optional override; defaults to the JSON’s project_id |
notifications.providers.fcm.service_account | Full service-account.json string (encrypted) |
Client-side integration
Section titled “Client-side integration”OneSignal — one line per platform
Section titled “OneSignal — one line per platform”After your user signs into vaultbase, bind the OneSignal device to the vaultbase user id:
// React / webimport OneSignal from "react-onesignal";await OneSignal.init({ appId: ONESIGNAL_APP_ID });
const { record } = await vb.auth.users.login({ email, password });await OneSignal.login(record.id); // ← maps external_id// iOSOneSignal.login(vbUser.id)// AndroidOneSignal.login(vbUser.id)On logout, call OneSignal.logout() so a shared device doesn’t keep
buzzing the previous user.
FCM — register the token with vaultbase
Section titled “FCM — register the token with vaultbase”Get the FCM registration token client-side, then POST it to vaultbase:
import { getMessaging, getToken } from "firebase/messaging";const token = await getToken(getMessaging(firebaseApp), { vapidKey: WEB_PUSH_VAPID_PUBLIC_KEY,});
await vb.client.send("POST", "/notifications/devices", { body: { token, provider: "fcm", platform: "web" },});On logout: DELETE /notifications/devices/<token> — soft-disables the
row. Re-registering with a new user id rebinds (UNIQUE on token).
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/v1/notifications/devices | user JWT | Upsert by token; sets enabled = 1, last_seen = now |
DELETE /api/v1/notifications/devices/:token | user JWT | Soft delete (enabled = 0) — caller-id checked |
Triggering from a hook
Section titled “Triggering from a hook”// afterCreate hook on `messages`const recipientId = ctx.record.recipient;if (!recipientId || recipientId === ctx.record.sender) return;
await ctx.helpers.notify(recipientId, { title: `New message from ${ctx.record.sender_name}`, body: String(ctx.record.body).slice(0, 140), data: { type: "message", id: ctx.record.id, deep_link: `/messages/${ctx.record.id}` },});Behavior:
- Insert one row in
vb_notifications(drives realtime broadcast for any client subscribed to the user’s inbox). - Enqueue one
_notifyqueue job per enabled provider. - Built-in worker picks up each job, calls the matching driver. On transient failure (5xx, 429, network) it throws → queue retries with exponential backoff. On permanent failure (UNREGISTERED, 401, etc.) it returns cleanly — and for FCM, marks the dead token disabled.
Per-call options
Section titled “Per-call options”await ctx.helpers.notify(userId, payload, { providers: ["fcm"], // restrict — default is all enabled inbox: false, // skip the inbox row (silent data push) push: false, // skip the queue fan-out (in-app only)});Auto-bootstrapped collections
Section titled “Auto-bootstrapped collections”notifications (per-user inbox)
Section titled “notifications (per-user inbox)”| Field | Type | Notes |
|---|---|---|
user | relation → users (cascade) | Owner |
type | text | Caller-supplied taxonomy (comment.reply, etc.) |
title | text (required) | |
body | text | |
data | json | Deep-link payload |
read_at | date (nullable) | Mark-as-read timestamp |
created_at | autodate | Implicit |
Rules: list/view/update/delete = user = @request.auth.id; create = admin-only
(server uses raw SQL).
device_tokens (FCM / APNs registry)
Section titled “device_tokens (FCM / APNs registry)”| Field | Type | Notes |
|---|---|---|
user | relation → users (cascade) | Owner |
provider | select: fcm | apns | OneSignal devices never appear here |
token | text (UNIQUE) | Raw FCM/APNs registration token |
platform | select: ios | android | web | |
app_version | text | Optional |
enabled | bool | Soft-delete flag |
last_seen | date | Bumped on every re-register |
created_at | autodate | Implicit |
Rules are admin-only across the board; clients hit the dedicated
/notifications/devices endpoints which write directly via raw SQL.
Provider-specific notes
Section titled “Provider-specific notes”OneSignal: the recipients=0 warning
Section titled “OneSignal: the recipients=0 warning”OneSignal returns { id, recipients } per send. If recipients == 0,
the worker logs a warning and does not retry — recipients = 0
almost always means the client never called OneSignal.login(externalId).
Check the device’s OneSignal SDK integration, not your vaultbase config.
FCM: which errors disable a token
Section titled “FCM: which errors disable a token”The worker disables (enabled = 0) tokens that come back with any of:
UNREGISTERED— app uninstalled, token revoked, restored from backupINVALID_ARGUMENT— malformed token (rare, usually a client bug)SENDER_ID_MISMATCH— wrong project — token belongs to another senderNOT_FOUND— same as UNREGISTERED in newer FCM responses
Other 4xx are logged + dropped (no retry, no disable). 5xx / 429 trigger queue retry with exponential backoff capped at one day.
FCM: OAuth access token caching
Section titled “FCM: OAuth access token caching”Each client_email from the service account gets one cached access
token, refreshed when within 5 minutes of expiry. Repeated sends from
the same dispatcher tick mint the JWT and exchange for the access token
once. Don’t worry about Google rate-limiting the OAuth endpoint at
scale.
FCM: data payload values must be strings
Section titled “FCM: data payload values must be strings”FCM v1 rejects non-string values inside message.data. The driver
auto-stringifies — but be aware that data: { count: 42 } becomes
"count": "42" on the wire.
Sending a test from the admin UI
Section titled “Sending a test from the admin UI”Settings → Notifications → Send test notification:
- Enter a vaultbase user id.
- Pick a provider (or “All enabled”).
- Click Send. Vaultbase enqueues the same flow
helpers.notifyruns. - Watch the result in Queues → Job Log (
queue = _notify) and on the user’s device.
Debugging “why didn’t user X get my push?”
Section titled “Debugging “why didn’t user X get my push?””Three places to look, in order:
vaultbase_jobs_log— was the job enqueued? Did it succeed? What did the provider return? (Admin → Queues → filterqueue = _notify).- The worker log line — for OneSignal,
recipients=0means client integration bug. For FCM, “transient” means provider/network blip; “dead” means the token’s gone. - Provider dashboard — OneSignal’s Delivery view + Firebase console show OS-level state (notification disabled, app uninstalled) that vaultbase has no visibility into.
Operational notes
Section titled “Operational notes”- Settings encryption. API keys and the FCM service account JSON
contain a private key — set
VAULTBASE_ENCRYPTION_KEYon production hosts so secrets are AES-GCM-encrypted at rest. Without the env var, values are stored plaintext (with a one-time stderr warning per key). - Cluster mode caveat. The realtime in-app channel is per-worker
(this affects the inbox row → live UI broadcast, not push). If you
run
bun src/cluster.ts, use sticky sessions at the load balancer until cross-worker pub/sub lands in core. Push fan-out is unaffected (stateless HTTP). - Per-user prefs. Not bundled. Add a
notification_prefsJSON field on the user collection and gate inside your hook before callinghelpers.notify. - Quiet hours / dedup. Use the queue’s
unique_keymechanism viahelpers.enqueue("_notify", ..., { uniqueKey: "msg:<id>" })if you prefer manual control overhelpers.notify’s default fan-out.