Skip to content

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.

ProviderIdentifierAuthVaultbase stores tokens?
OneSignalexternal_id (= vaultbase user id)Authorization: Basic <REST API Key>No — OneSignal owns the device layer
FCMraw FCM registration token per deviceOAuth2 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.

  1. Sign up at onesignal.com, create an App, configure platforms (OneSignal hosts your APNs .p8 and FCM Service Account internally).
  2. Copy App ID and REST API Key from Settings → Keys & IDs.
  3. In vaultbase admin → Settings → Notifications → OneSignal: paste both, toggle Enabled, click Save.
  4. Click Test connection — vaultbase hits GET /apps/<id> with the key; ✓ confirms the credentials are valid.
  1. In Firebase console → Project Settings → Service Accounts → Generate new private key. Save the downloaded JSON.
  2. In vaultbase admin → Settings → Notifications → FCM: paste the full JSON contents into Service Account, optionally fill Project ID (defaults to project_id from the JSON).
  3. Toggle Enabled, click Save.
  4. 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.

Stored in vaultbase_settings. Encrypted at rest when VAULTBASE_ENCRYPTION_KEY is set.

KeyNotes
notifications.providers.onesignal.enabled"1" or "0"
notifications.providers.onesignal.app_idOneSignal App UUID
notifications.providers.onesignal.api_keyREST API Key (encrypted)
notifications.providers.fcm.enabled"1" or "0"
notifications.providers.fcm.project_idOptional override; defaults to the JSON’s project_id
notifications.providers.fcm.service_accountFull service-account.json string (encrypted)

After your user signs into vaultbase, bind the OneSignal device to the vaultbase user id:

// React / web
import 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
// iOS
OneSignal.login(vbUser.id)
// Android
OneSignal.login(vbUser.id)

On logout, call OneSignal.logout() so a shared device doesn’t keep buzzing the previous user.

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).

EndpointAuthPurpose
POST /api/v1/notifications/devicesuser JWTUpsert by token; sets enabled = 1, last_seen = now
DELETE /api/v1/notifications/devices/:tokenuser JWTSoft delete (enabled = 0) — caller-id checked
// 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:

  1. Insert one row in vb_notifications (drives realtime broadcast for any client subscribed to the user’s inbox).
  2. Enqueue one _notify queue job per enabled provider.
  3. 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.
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)
});
FieldTypeNotes
userrelation → users (cascade)Owner
typetextCaller-supplied taxonomy (comment.reply, etc.)
titletext (required)
bodytext
datajsonDeep-link payload
read_atdate (nullable)Mark-as-read timestamp
created_atautodateImplicit

Rules: list/view/update/delete = user = @request.auth.id; create = admin-only (server uses raw SQL).

FieldTypeNotes
userrelation → users (cascade)Owner
providerselect: fcm | apnsOneSignal devices never appear here
tokentext (UNIQUE)Raw FCM/APNs registration token
platformselect: ios | android | web
app_versiontextOptional
enabledboolSoft-delete flag
last_seendateBumped on every re-register
created_atautodateImplicit

Rules are admin-only across the board; clients hit the dedicated /notifications/devices endpoints which write directly via raw SQL.

OneSignal returns { id, recipients } per send. If recipients == 0, the worker logs a warning and does not retryrecipients = 0 almost always means the client never called OneSignal.login(externalId). Check the device’s OneSignal SDK integration, not your vaultbase config.

The worker disables (enabled = 0) tokens that come back with any of:

  • UNREGISTERED — app uninstalled, token revoked, restored from backup
  • INVALID_ARGUMENT — malformed token (rare, usually a client bug)
  • SENDER_ID_MISMATCH — wrong project — token belongs to another sender
  • NOT_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.

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 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.

Settings → Notifications → Send test notification:

  1. Enter a vaultbase user id.
  2. Pick a provider (or “All enabled”).
  3. Click Send. Vaultbase enqueues the same flow helpers.notify runs.
  4. 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:

  1. vaultbase_jobs_log — was the job enqueued? Did it succeed? What did the provider return? (Admin → Queues → filter queue = _notify).
  2. The worker log line — for OneSignal, recipients=0 means client integration bug. For FCM, “transient” means provider/network blip; “dead” means the token’s gone.
  3. Provider dashboard — OneSignal’s Delivery view + Firebase console show OS-level state (notification disabled, app uninstalled) that vaultbase has no visibility into.
  • Settings encryption. API keys and the FCM service account JSON contain a private key — set VAULTBASE_ENCRYPTION_KEY on 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_prefs JSON field on the user collection and gate inside your hook before calling helpers.notify.
  • Quiet hours / dedup. Use the queue’s unique_key mechanism via helpers.enqueue("_notify", ..., { uniqueKey: "msg:<id>" }) if you prefer manual control over helpers.notify’s default fan-out.