Push messaging
Native push (FCM / APNs / Web Push) with per-workspace providers, device registration, templates, and flow/sandbox fan-out.
Native push notifications to mobile and browser devices, alongside the in-app notifications feed. Push uses the same adapter + per-workspace config model as email — pick a provider, store its credentials (encrypted at rest), and send.
Providers
Section titled “Providers”| Provider | Targets | Runtime notes |
|---|---|---|
fcm | Android + cross-platform (and iOS/web via Firebase) | HTTP v1 API — works everywhere, including Cloudflare Workers. |
apns | iOS native | Token-based (.p8). Direct APNs needs an HTTP/2 runtime (Cloudflare Workers). On Node/Bun (HTTP/1.1) route iOS through fcm instead. |
web-push | Browsers | VAPID + RFC 8291 aes128gcm encryption — works everywhere. |
console | — | Dev only; logs the notification to stdout. |
All three real providers can be active at once: when several have credentials,
Backlex composes them into one fan-out adapter and routes each device by its
platform.
Configuration
Section titled “Configuration”Two layers, resolved in order (same as email): the workspace’s own push_config
row → the instance-wide _global row → the deployment’s PUSH_* env vars.
Deployment env
Section titled “Deployment env”# Force one provider, or leave unset to auto-compose every configured one.PUSH_PROVIDER= # fcm | apns | web-push | console
# Firebase Cloud Messaging (from the service-account JSON)FCM_PROJECT_ID=my-appFCM_CLIENT_EMAIL=firebase-adminsdk@my-app.iam.gserviceaccount.comFCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----"
# Apple Push (token-based .p8)APNS_KEY_ID=ABC123DEFGAPNS_TEAM_ID=DEF456GHIJAPNS_BUNDLE_ID=com.example.appAPNS_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----"APNS_PRODUCTION=true # false → sandbox gateway
# Web Push (VAPID) — straight from `npx web-push generate-vapid-keys`WEBPUSH_SUBJECT=mailto:admin@example.comWEBPUSH_VAPID_PUBLIC_KEY=BPx… # raw base64url public pointWEBPUSH_VAPID_PRIVATE_KEY=0jQ… # raw base64url 32-byte scalar (NOT a PEM)Per-workspace (admin UI)
Section titled “Per-workspace (admin UI)”Settings → Push mirrors the Email tab: choose a provider, enter its config
and secret(s), and Send test push to your own registered devices. Secrets
are encrypted (AES-256-GCM, key derived from AUTH_SECRET) and never returned —
the form only shows a per-key “stored” flag.
Device registration
Section titled “Device registration”A device registers itself through the authenticated end-user app. A device is
keyed by (user, platform, token); re-registering the same token reactivates it
and refreshes last_seen_at, so clients can safely register on every launch.
Tokens the provider rejects as permanently gone (HTTP 404/410) are deactivated
automatically on the next send.
import { createClient } from "backlex";const client = createClient({ url: "https://api.example.com", token });
// FCM / APNs — token is the registration / device tokenawait client.messaging.registerDevice({ platform: "fcm", token: fcmToken, deviceName: "Pixel 9" });
// Web Push — pass the PushSubscription's endpoint + keysconst sub = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });await client.messaging.registerDevice({ platform: "web-push", token: sub.endpoint, keys: { p256dh: /* base64url */, auth: /* base64url */ },});
await client.messaging.listDevices();await client.messaging.unregister(deviceId);REST equivalents: POST /api/device-tokens, GET /api/device-tokens,
DELETE /api/device-tokens/{id}.
Sending
Section titled “Sending”From a flow
Section titled “From a flow”A push operation sends to one user’s devices; userId may be a literal id or
a {{ data.author }} template:
{ "type": "push", "userId": "{{ data.author }}", "title": "New reply", "body": "{{ data.preview }}", "url": "/posts/{{ data.postId }}" }From a function (sandbox)
Section titled “From a function (sandbox)”await ctx.push.send({ userId: ctx.data.author, title: "Order shipped", body: "Track it now", url: "/orders/123" });Templates
Section titled “Templates”Reusable push_templates (title/body/url with {{ var }} interpolation) are
managed under the admin API (/api/admin/push-templates) and rendered at send
time, same as email templates: a key resolves to the workspace’s own row, and
falls back to a global (tenant_id IS NULL) one.
Pass templateKey wherever you would pass title/body. Those two stay
allowed alongside it and become the fallback if the key resolves to
nothing — the same contract email has.
# RESTcurl -X POST $APP_URL/api/messaging/push -H 'content-type: application/json' \ -d '{"userId":"usr_1","templateKey":"order_shipped","vars":{"order":{"id":"A-42"}}}'
# CLIbun backlex messaging send-push --user usr_1 --template order_shipped \ --vars '{"order":{"id":"A-42"}}'// SDKawait client.messaging.sendPush({ userId, templateKey: "order_shipped", vars: { order } });// flow op — `vars` is interpolated with the flow payload first, then the// template renders against the result{ "type": "push", "userId": "{{ data.author }}", "templateKey": "order_shipped", "vars": { "order": "{{ data.order }}" } }POST /api/admin/push-templates/{id}/send-test renders the same way through
the same code path, so the preview is the send.
Not on the push MCP tool: messaging.send_push posts to /api/notifications,
which writes an in-app row whose own title/body is the message — a different
surface with a different store, not this one.
In-app vs push
Section titled “In-app vs push”The notifications feed (/api/notifications) is the in-app
bell; push reaches the OS/browser. They compose — set push: true on a
notification (or on a flow notification op) to also fan out to the target
user’s registered devices:
{ "type": "notification", "userId": "{{ data.author }}", "title": "New reply", "push": true }Push fan-out is best-effort: the in-app row always lands even if the device send
fails, and broadcasts (userId: null) never push (push needs a concrete
recipient with registered devices).
Multi-tenant: each workspace brings its own keys
Section titled “Multi-tenant: each workspace brings its own keys”Unlike email, there is no shared managed-push fallback — and that’s by
design, not an omission. A device push token is bound to the app’s own provider
project: an FCM registration token only accepts sends from the same Firebase
project that minted it, and a web-push subscription only accepts sends signed by
the VAPID keypair the browser subscribed with. So a platform-wide key cannot
deliver to a tenant’s users — every workspace must configure its own
FCM / APNs / VAPID credentials under Settings → Push (push_config).
Those per-workspace credentials are resolved and used locally on the tenant’s
worker (secrets AES-encrypted at rest), so one workspace can never push to
another’s devices — isolation holds at the device-registry level too (a send only
ever targets tokens from that workspace’s own device_tokens).
The control-plane push gateway (/api/internal/push/send) exists for a future
managed-push offering where the platform provisions per-tenant credentials
centrally; it is unused unless platform PUSH_* vars are set, and a shared
platform key would only reach apps built under that same platform project.