Loomup Docs
Guide /docs/push

Push notifications

Application developers own notification wording, destinations, and types. Recipients control what they receive. Loomup renders the declared content and handles durable delivery through Expo Push, FCM HTTP v1, APNs token authentication, and Web Push. This is separate from realtime WebSocket live sync: WebSockets update connected clients; push wakes backgrounded apps via the platform notification service.

Overview

  1. App registers a device token with POST /push/devices (authenticated).
  2. Table config enables push = true and a notify rule.
  3. Writes to that table produce CDC events; the server enqueues _push_outbox rows.
  4. A background worker delivers through the device's provider and prunes invalid tokens.

Configuration

toml
[push]
enabled = true
max_devices_per_user = 20
outbox_batch_size = 50
outbox_poll_interval_ms = 200
max_attempts = 8
default_title = "Update"
default_ops = ["INSERT", "UPDATE"]
expo_access_token_env = "EXPO_ACCESS_TOKEN"
fcm_project_id_env = "FCM_PROJECT_ID"
fcm_service_account_path_env = "FCM_SERVICE_ACCOUNT_PATH"
apns_key_path_env = "APNS_KEY_PATH"
apns_key_id_env = "APNS_KEY_ID"
apns_team_id_env = "APNS_TEAM_ID"
apns_topic_env = "APNS_TOPIC"
apns_production = true

For hosted projects, configure encrypted project credentials in Web Studio under Push or with loomup push-provider. Hosted runtimes never fall back to process-wide environment credentials. Expo remains available without a token; storing one is optional and is needed only when Expo push security is enabled. Standalone servers load an encrypted project credential first and retain the environment-variable fallback shown above.

ProviderCredentials
ExpoOptional EXPO_ACCESS_TOKEN (works without token with lower limits)
FCMService account JSON path + project id
APNs.p8 key path, key id, team id, topic (bundle id); apns_production selects production vs sandbox
Web PushVAPID public/private keys and subject

Provider files for hosted CLI commands:

console
# Expo: {"access_token":"..."}
# FCM: the raw Firebase service-account JSON
# APNs: {"key_id":"...","team_id":"...","topic":"com.example.app","private_key_p8":"...","production":true}
# Web Push: {"public_key":"...","private_key":"...","subject":"mailto:admin@example.com"}
loomup push-provider put fcm --file ./firebase-service-account.json --project <id>
loomup push-provider status --project <id>
loomup push-provider delete fcm --project <id>

Credentials are AES-256-GCM encrypted under .loomup/secrets/push-providers/ using the base64-encoded 32-byte LOOMUP_PLATFORM_SECRETS_KEY. Mutation is atomic; hosted activation reloads only the selected project and restores the previous credential if reload fails. APIs and status commands return metadata, never secrets.

Portable per-table declaration

loomup.schema.yaml supports $push; Studio Resources expose the equivalent top-level push manifest declaration:

yaml
$push:
  enabled: true
  tables:
    messages:
      recipient_fields: [recipient_id]
      operations: [insert]
      title: New message
      body: "{{body}}"

Operations default to insert and update, recipient fields default to user_id, and an omitted notify rule uses the table's effective read rule. Set notify: false explicitly to suppress delivery. When project-schema tables and Studio Resources coexist, push is enabled when either declaration has an enabled table.

The generated runtime configuration is equivalent to:

toml
[tables.messages]
expose = true
realtime = true   # optional; push can work with realtime=false if push=true
push = true
notify_user_fields = ["recipient_id"]
push_ops = ["INSERT"]
push_title = "New message"
push_body = "{{body}}"

[tables.messages.rules]
read = "recipient_id = auth.uid() OR sender_id = auth.uid()"
create = "auth.uid() != null"
notify = "recipient_id = auth.uid()"
  • A declared push table's notify defaults to its effective read rule; tables not declared for push remain fail-closed.
  • Candidates are taken from notify_user_fields (or user_id if fields are empty).
  • Each candidate is kept only if notify evaluates true for that user (no admin rule bypass).
  • CDC triggers install when the table is exposed and (realtime with server realtime or push with server push).
  • Notification data includes table, op, id, sequence, and optional declared data and destination URL. It never includes the full row. Reserved transport keys cannot be overridden.

Conditional events with multiple recipients

A notification event can use either the existing recipient source field or a non-empty recipients list. An optional when map combines conditions with AND. Each condition contains exactly eq with a non-null scalar JSON value, in with a non-empty list of non-null scalar values, or is_null with a boolean. Use is_null: true for null and is_null: false for non-null. This explicit operator round-trips through Loomup’s persisted TOML configuration. Conditions use source fields or validated foreign-key paths; unknown fields and operators are rejected.

yaml
# Under $notifications.events; declare the type, template and mapped inbox fields too.
- type: issue_completed
  source: issues
  operations: [UPDATE]
  changed: status_id
  recipients: [created_by, assignee_id]
  when:
    status_id.type: {in: [completed, closed]}
    deleted_at: {is_null: true}
  fields:
    push_recipient_id: $recipient
    issue_id: id
    actor_name: $actor.name

The journal's committed after-image supplies recipient IDs and source values (the before-image is used for DELETE). Foreign-key conditions resolve against current related rows when the event is projected, just like mapped relation fields. Editing a related status definition alone does not trigger this event. The authenticated journal actor is used unless actor is explicitly configured; never trust a client-authored updated_by field for self-delivery suppression.

Missing/null recipients, duplicate user IDs and the actor are omitted. $recipient maps the current recipient ID, including for push delivery fields. All recipients for a rule commit in one transaction; failures roll back the group and the durable consumer retries. Multi-recipient keys encode the legacy base key plus recipient ID as a JSON tuple with a recipients: prefix. Without dedupe, the base key includes the notification type and journal event ID, so reopening and completing again is a new notification. Existing recipient configurations retain their keys and custom dedupe behavior unchanged. Inbox read rules and push notify rules continue to determine access. Push preferences affect delivery, not inbox creation. Provider delivery remains at-least-once; this guarantees idempotent inbox projection, not exactly-once provider delivery.

Shared notification catalog

Declare presentation once in $notifications in loomup.schema.yaml, or the equivalent notifications block in a Studio Resource manifest:

yaml
$notifications:
  table: notifications
  presentation_field: presentation
  scopes: {workspace: workspace_id, project: project_id}
  fallback:
    title: New notification
    body: You have a new notification.
  templates:
    mention:
      label: Mentions
      title: "{{actor_name}} mentioned you"
      body: "{{workspace_name}} · #{{issue_number}} {{issue_title}}"
      url: "/n/{{id}}"
      data: {type: mention}
  # Keep your existing events declaration here.

The inbox must declare a nullable presentation: json? field, the configured recipient/actor/type/event-key columns, and a unique event-key index. Existing event declarations continue to project inbox rows from the durable journal. Projection sources capture history even when live subscriptions are disabled. Templates can also be declared without an inbox for push-only applications.

The backend saves a versioned presentation snapshot in the same transaction as each new inbox row. Missing SQL defaults are evaluated once before rendering and those exact values are inserted, including for journal projections; explicitly supplied values and nulls are preserved. Inbox UI and push both use this snapshot; editing a template changes future notifications. Legacy rows without a snapshot remain readable and should retain the application's existing display fallback. Do not backfill presentation by re-creating old notifications.

Schema planning includes the history capture and effective notify rules required by the notification declaration. These derived settings do not appear as pending changes when planning the same schema again after applying it, including when source tables have realtime disabled.

$notifications does not overwrite explicit $push settings, including recipient fields and disabled delivery. For example, use recipient_fields: [push_recipient_id] to keep imported history quiet when that field is null. Keep push operations at [insert] so read acknowledgements do not produce notifications. Legacy declarations without templates or a presentation field retain implicit push only when no explicit portable push declaration exists.

One project may have one inbox. Schema and Studio catalogs can coexist, but duplicate type/scope ownership and conflicting custom fallbacks are rejected. Studio's Push editor saves catalogs with the existing schema revisions and previews unsaved templates using the backend renderer. Preview never sends a notification. Recent diagnostics show fallback reasons and each device's sent, suppressed, invalid, retry, or failed status.

Recipient deletion

Managed inbox rows use the normal resource DELETE endpoint. Both the table's read and delete rules must allow the current user to access the existing row. With the workspace-project access profile, deletion is disabled by default; set notifications[].allowDelete: true and apply the access migration to allow recipients to delete notifications they can read. This applies to read and unread notifications and retains workspace/project access checks. Deletion removes the inbox row; it does not change notification preferences or retract delivered push.

Ordinary users still cannot create managed inbox rows or rewrite their content or recipients, even with permissive table rules. Updates remain limited to read_at, deleted_at, and updated_at, subject to the table's read and update rules. For soft deletion, declare deleted_at: datetime? in the inbox schema and PATCH its timestamp together with updated_at. This retains the row and its event key; filter deleted_at to null in inbox lists and unread counts. The generic DELETE endpoint still performs hard deletion when the delete rule allows it.

Rendering and destinations

Use scalar {{field}} placeholders from the final inbox row or send context. Strings, numbers, and booleans are supported; nested paths, expressions, arrays, and objects are not presentation substitutions. Mute scopes must map to declared scalar inbox fields when an inbox is configured; push-only contexts use the fields supplied by the sender. Unicode is preserved. URL substitutions are encoded as URL components; the origin must be literal, including with uppercase HTTPS schemes. Destinations must be relative paths or absolute https:// URLs without credentials. Query and fragment substitutions are supported even without a path. Applications should allow only their own destination origins when handling a click.

Title/body templates are limited to 512/4096 UTF-8 bytes; rendered title/body to 128/256 Unicode characters. Custom data contains strings, is limited to 2048 serialized bytes, and cannot use aps, from, message_type, table, op, id, sequence, notification_id, url, or google./gcm. prefixes. Rendered title/body/url/data must fit 3000 serialized bytes, leaving space for provider metadata. FCM documents its payload limits and reserved keys.

Missing templates or empty/missing required title/body values use the generic fallback, with template_missing or content_missing diagnostics. Invalid/missing destinations and data are omitted with diagnostics. Oversized rendered payloads fall back to generic content. Malformed templates and invalid literal send content are rejected. Raw database operation strings are never generated as fallback copy.

Trusted application sends

Use a server-side service key with project:backend scope. User access tokens and resource-only keys cannot send arbitrary notifications.

ts
const server = createClient({ url: projectUrl, serviceKey: backendKey });
const receipt = await server.push.send({
  type: "mention",
  recipients: [recipientId],
  idempotency_key: `mention:${commentId}:${recipientId}`,
  channels: ["inbox", "push"],
  fields: {
    actor_id: actorId, actor_name: "Asha", workspace_id: workspaceId,
    workspace_name: "Design", project_id: projectId,
    issue_number: 42, issue_title: "Review the new screen",
  },
});

channels defaults to both. Choose ["inbox"] for quiet inbox creation or ["push"] for delivery without an inbox row. An inbox send supplies application-specific required columns in fields; Loomup owns IDs, recipient/type/event-key/presentation fields, push recipient fields, and timestamps. Optional content: {title, body, url?, data?} supplies literal presentation for the declared type instead of its template.

POST /push/notifications atomically commits all recipients and the idempotency receipt, returning 202 with {data: {dispatch_id, notification_ids, accepted}}. Identical retries return the original receipt even after catalog changes. Reusing a key with different content returns 409. Invalid or inaccessible recipients roll back the whole request. Acceptance means durable work was recorded, not that a provider delivered it. Inbox sends enforce recipient read and notify rules; ordinary users can update only acknowledgement fields on a managed inbox.

Recipient preferences

ts
const catalog = await client.push.catalog();
const preferences = await client.push.preferences.get();
await client.push.preferences.update({
  ...preferences,
  preview: "hidden",
  types: { ...preferences.types, mention: false },
  muted_scopes: [{ kind: "workspace", id: workspaceId }],
});

Preferences belong to the signed-in user, independently of devices. Defaults enable all declared types with full previews. enabled: false, a disabled type, or any matching muted scope suppresses push. Hidden previews use the developer's literal generic fallback. These settings never suppress inbox creation. Preserve returned fields and revision when saving; a stale revision returns 409 and requires a reload. Existing settings for retired types/scopes can be retained or removed.

Delivery rechecks current preferences, active recipient status, table exposure, and managed-inbox recipient fields and read/notify access before each provider attempt. Queued inbox messages retain these checks even if their catalog is later removed; upgrading preserves this requirement for existing pending messages. Clearing or reassigning a push recipient field, or changing its mapping, suppresses queued delivery to the old recipient without contacting the provider. A muted or revoked recipient is also suppressed. Retries keep the original device set and skip recorded successes; removed or reassigned devices are suppressed. Partial outcomes remain visible in diagnostics.

REST endpoints

MethodPathDescription
POST/push/devicesRegister / upsert token
GET/push/devicesList current user's active devices
DELETE/push/devices?token= or ?id=Unregister by query param
DELETE/push/devices/{id}Unregister by path id
GET/push/web-configGet the browser-safe VAPID public key
GET/push/catalogUser-visible type labels and mute scope names
GET, PUT/push/preferencesCurrent user's revisioned preferences
POST/push/notificationsTrusted, idempotent inbox/push send

Hosted project managers also have metadata-only control-plane endpoints:

  • GET|PUT /platform/api/projects/{id}/push/settings for revision-protected push declarations and notification catalogs. Omitted catalogs are preserved.
  • POST /platform/api/projects/{id}/notifications/preview for rendering {config, type, row} without saving or sending.
  • GET /platform/api/projects/{id}/push/diagnostics for the 50 most recent dispatches and per-device outcomes; no tokens or full payloads.
  • GET /platform/api/projects/{id}/push/providers and GET|PUT|DELETE /platform/api/projects/{id}/push/providers/{provider} for encrypted credentials.

Register body

json
{
  "token": "ExponentPushToken[…]",
  "provider": "expo",
  "platform": "ios",
  "device_id": "optional-install-id",
  "app_version": "1.0.0",
  "locale": "en-US"
}

provider is one of expo, fcm, apns, webpush. A Web Push token is the serialized browser PushSubscription. Auth: Authorization: Bearer <access_token>.

Response envelope matches REST: { "data": { …device… } }.

When [push].enabled = false, device endpoints and push-only sends return 503 with code push_disabled. Catalog, preferences, and inbox creation remain available.

Client usage

TypeScript

ts
import { createClient } from "@loomup/client";

const client = createClient({ url: "http://127.0.0.1:3000" });
await client.auth.signIn({ email: "a@b.com", password: "secret12" });

await client.push.registerDevice({
  token: expoPushToken,
  provider: "expo",
  platform: "ios",
});

// On logout / opt-out:
await client.push.unregisterDevice({ token: expoPushToken });

Obtain the token/subscription from Expo Notifications, FCM, APNs, or the browser Push API — Loomup does not embed those provider SDKs.

For browsers, fetch /push/web-config, pass data.public_key to PushManager.subscribe, then register JSON.stringify(subscription.toJSON()) with provider webpush and platform web. The private VAPID key never leaves the server.

React Native

Use @loomup/react-native for auth storage, then the same client.push.* methods after permission is granted and a token is available.

Lifecycle

  • Register after sign-in and OS permission / token refresh.
  • Unregister on sign-out when the user opts out of push.
  • Server disables devices when a user is disabled or password is reset.
  • Invalid tokens (DeviceNotRegistered, FCM unregistered, APNs 410) are auto-disabled.

Admin

  • Push tab: devices, outbox status, metrics.
  • GET /admin/api/push/devices, POST /admin/api/push/devices/{id}/disable, GET /admin/api/push/outbox.
  • Admin is monitoring-only. Use Web Studio or the CLI to configure hosted credentials and delivery rules.

Security

  • Provider server keys never go to clients.
  • Device routes are user-scoped; admin can list/disable any device.
  • Web Push subscriptions require a public HTTPS service hostname on port 443, without URL credentials or fragments, plus correctly sized P-256 and auth keys. Literal IP, localhost, and local/internal hostnames are rejected before storage and rechecked before delivery.
  • Use tight notify rules and push_ops to avoid spam from high-churn tables.
  • Device registration is rate-limited per user (same order of magnitude as auth attempts).

Limitations

  • At-least-once delivery to providers; clients should dedupe via data.notification_id or data.sequence. Per-device receipts prevent resending recorded successes during retries. A crash between provider acceptance and receipt persistence can still cause a duplicate.
  • Single-process outbox claim (matches SQLite single-server deployment).
  • Distinct from realtime: background OS notifications vs connected WebSocket updates.

See also

iOS communication notification avatars

A notification template can opt in to an iOS Notification Service Extension with data: { notification_service: communication }. At delivery time Loomup adds notification_preview: full and APNs sets aps.mutable-content to 1. When the recipient hides previews, Loomup removes the service marker, sets the preview state to hidden, and sends the configured fallback as an ordinary alert. The preview flag is server-owned and overrides any supplied value.

The application must ship and embed a Notification Service Extension, enable Communication Notifications, and declare INSendMessageIntent in NSUserActivityTypes. Its extension fetches the authorized inbox row and avatar using the notification id, then donates an incoming message intent and updates the notification content. Keep a bounded download and return the original alert on timeout, missing authentication, or an unavailable avatar. Do not put session tokens or private avatar URLs in notification data. Existing app versions safely ignore the service marker and continue displaying the alert.

Atomic inbox actions and selections

The configured $notifications.table supports recipient-owned bulk read and soft-delete actions, independently of push delivery enablement. It must have a single-column primary key and the conventional read_at, deleted_at, created_at, and updated_at columns. These endpoints require a user session, not a backend service key. Workspace/project row policies remain authoritative, including at execution time.

POST /inbox/selections with {"scope":{"workspace_id":"w1"}} stores the exact authorized, undeleted IDs and returns {data:{selectionId,count,cutoff,expiresAt}}. Scope is optional for an account-wide inbox. Existing rows are included regardless of their created timestamp, so clock skew cannot omit visible notifications. A selection expires in 30 minutes; new arrivals and backfills inserted later are never included. POST /inbox/selections/{id}/members with the same scope and an ids array returns {data:{ids}} for currently accessible, undeleted snapshot members. Use it to render checkboxes after loading another page.

POST /inbox/actions accepts:

json
{
  "action": "mark-read",
  "scope": {"workspace_id": "w1"},
  "target": {"kind":"selection","selectionId":"snapshot-id","excludedIds":[],"includedIds":[]},
  "idempotencyKey": "one-stable-key-per-user-action"
}

Actions are mark-read, delete, and delete-read. Targets may also be {kind:"ids",ids:[...]} or {kind:"matching"}. Matching actions capture a server cutoff; supply an earlier cutoff for issue-visit acknowledgement. Delete-read preserves rows whose read timestamp is later than the cutoff. Mark-read preserves existing read timestamps. Deleted rows and their event keys are retained, preventing duplicate backfills.

The response is {data:{changed,skipped,cutoff,changedAt}}; timestamps use Unix milliseconds. A single transaction checks policies, updates the authorized set, captures each row's journal event, and saves the response. Errors roll back the transaction. Invalid explicit IDs reject the action; stale/deleted/inaccessible saved members are skipped. Reuse the exact request and idempotency key after a lost response: receipts last 24 hours, and conflicting reuse returns 409. Expired selections return 410 and require explicit reselection, never silent expansion. Clients reconcile through realtime or durable sync after acknowledgement; cancelling a client request does not cancel a committed transaction.

Limits: 50,000 candidate/snapshot members, 1,000 IDs in each explicit/addition/exclusion/membership list, 16 scope predicates, and 100 active snapshots per recipient. Scope cannot override recipient, primary-key, or lifecycle fields. Limits reject the operation rather than silently truncating it. Expired receipts and old selections are cleaned during subsequent inbox operations. Existing single-row APIs remain compatible.

The TypeScript SDK exposes client.inbox.select(scope), client.inbox.members(selectionId, ids, scope), and client.inbox.act(request). Swift exposes the corresponding client.inbox API and InboxActionRequest; keep the same request for retries. These operations are online-only and are not added to an offline mutation queue.