Skip to content

Push notifications

Multi-provider push (OneSignal + FCM) with one helpers.notify call. Provider-agnostic trigger surface, auto-bootstrapped inbox, retry-aware queue worker, dead-token cleanup.

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.

Both belong to the users of an auth collection named users, which must exist first. Without it the collections aren’t created: the save answers with bootstrap_error: "Notifications need an auth collection named 'users' …". Create the collection, then enable the provider again.

Stored in vaultbase_settings. The API key and service account are encrypted at rest (see Operational notes).

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 res = await vb.auth.users.login({ email, password });
if ("mfa_required" in res) throw new Error("finish the MFA step first");
await OneSignal.login(res.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.request("/api/v1/notifications/devices", {
method: "POST",
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. It is announced like any created record, so realtime subscribers to notifications, webhooks and afterCreate hooks see it.
  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/delete = user = @request.auth.id; update = user = @request.auth.id && @request.body.user:isset = false (a user can’t move a row to someone else); create = admin-only (the server inserts the rows).

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 retry — recipients = 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 and network errors are retried:

  • If some of the user’s devices got the push and others failed this way, only the failed devices are retried, after 2, 4, 8 and 16 seconds. Devices that already got it don’t get it twice.
  • If none were delivered, the whole job is retried by the queue: up to 5 attempts, exponential backoff from 2 seconds, then dead.

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, in the provider’s section (the Send “Vaultbase test” via … row):

  1. Enter a vaultbase user id.
  2. Click Send. Vaultbase runs the same flow helpers.notify runs, restricted to that provider.
  3. 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. The OneSignal API key and the FCM service account JSON (which contains a private key) are always AES-GCM-encrypted at rest, with VAULTBASE_ENCRYPTION_KEY or, when it isn’t set, the key generated in <data dir>/.encryption-key. Back that key up with the database: without it the values can’t be read.
  • Cluster mode caveat. Under the deprecated vaultbase cluster the realtime in-app channel is per worker (this affects the inbox row → live UI broadcast, not push). Read worker threads, the default, and several servers on one Postgres database reach every subscriber. 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", { provider, userId, payload: { title, body, data } }, { uniqueKey: "msg:<id>" }) if you prefer manual control over helpers.notify’s default fan-out. Enqueue one job per provider ("onesignal" or "fcm"); this writes no inbox row.