Skip to main content
The public push engine sends through APNs and FCM using your app’s credentials. It shares campaign audiences, variants, goals, and delivery windows. Native notification rendering and action registration belong to your app integration. Follow the React Native setup guide for Expo and bare iOS/Android apps. Provider acceptance does not prove visible presentation.

Match the app and server configuration

Use the same app ID, platform, and environment in the SDK installation and provider credentials. On iOS, the APNs topic must match the signed bundle ID; the environment must match its APNs entitlement. On Android, the Firebase configuration must belong to the app that obtains the FCM token. The app requests OS permission at its chosen time and records product consent separately. Register native channels/categories/actions before advertising their capabilities. Confirm application identity before routing taps. Choose foreground display or suppression explicitly. Galinum uses raw APNs/FCM tokens, including with Expo; it does not send through the Expo Push Service. Self-hosted Galinum and Galinum Cloud use the same public management routes. Keep APNs keys and Firebase service-account private keys server-side. The app receives only its publishable key and native provider configuration.

Configure credentials

For persistent self-host storage, supply GALINUM_PUSH_ENCRYPTION_KEY as base64 encoding of 32 cryptographically random bytes. Keep this key stable, private, and backed up separately from the database. Losing it makes stored credentials unreadable. Memory-only servers can use a process-lifetime key and lose their configuration on restart. Credentials are encrypted with AES-256-GCM and bound to the project and credential ID. Reads never return private keys or ciphertext. Use the project secret for management requests:
For Android, use platform: "android" and credential: { provider: "fcm", projectId, clientEmail, privateKey } from your service account. The SDK registers native APNs/FCM tokens rather than Expo relay tokens. GET /api/v1/push/credentials returns up to 100 IDs, revisions and app metadata. Pass its nextCursor as afterId to read another page. Use the current revision to replace credentials; stale writes return 409. Unsent targets captured with an older credential revision are blocked. POST /api/v1/push/credentials/validate accepts { credential }. It validates the key and configuration locally and returns externalAuthentication: "not_checked". It does not claim that Apple or Google accepted the credential. A selected-device test exercises authentication and sending.

Create and launch

Send this body to POST /api/v1/campaigns. Existing audience, goal, weighted variant, and delivery-window fields work with push. The channel cannot change after creation. Selection supports last_active, all, or specific with an installationId. Permission, consent, app identity and reported capabilities must match. Token refresh does not establish app activity. Personalization supports shallow user-trait lookup with an optional JSON string fallback. It applies to title, body, iOS subtitle and action titles. Missing traits without a fallback fail the planning request. Website destinations and images require HTTPS. Apps own handling of allowed app URL schemes. Custom data is a string map; keys beginning with galinum are reserved.

Native actions and content

Apple categories support up to four actions, but the OS may show fewer in compact presentations; banners show at most two. Android notifications support up to three action buttons. Galinum preserves these limits without removing extra actions: content declaring Android settings rejects more than three. iOS-only content can use four. Put the most important actions first. Apple category documentation, Android notification documentation. An iOS client must register the category, then report its exact ordered action IDs and titles in installation facts:
This is the capabilities object in the existing facts mutation. Duplicate category/action IDs are rejected. Planning and pre-dispatch validation both require the category ID and all ordered IDs/titles to match the rendered push content. Personalizing an action title requires an identically registered title. Android requires registered action IDs and uses the rendered payload titles for button labels. A category name alone is insufficient on iOS. The registration report is supplied by the app; physical-device validation still matters. APNs uses alert payloads with a galinum envelope, category, subtitle, sound, badge and optional mutable-content image handling. iOS images need a Notification Service Extension. Android receives data-only FCM messages; its native receiver must build the notification, channel, image and action PendingIntents. Do not replace this path with an automatic carrier that drops the required actions. OS background/force-stop restrictions can prevent receipt. The complete compiled payload must fit 4096 UTF-8 bytes; Galinum reports payload failure instead of truncating fields. APNs uses HTTP/2 with ES256 provider JWTs. FCM uses service-account OAuth and the HTTP v1 send endpoint. APNs protocol, FCM HTTP v1.

Worker, retries, and expiration

The shipped self-host CLI automatically runs a push worker. After ordinary campaign launch, it checks delivery windows and eligibility, freezes device selection, and processes due attempts. Polling defaults to 1000 ms; set GALINUM_PUSH_WORKER_INTERVAL_MS between 100 and 60000 ms to change it. Passes do not overlap. SIGTERM/SIGINT stop polling, await the current pass, and close the server and provider connections. Library factories remain explicit: a host calls engine.runPass({ maxUnits, maxPages, deadline }) for bounded admission, or product.push.runDue() for the CLI’s default pass. See runtime invocation and workers for limits and progress. An admitted dispatch finishes settlement after its deadline; admission does not bound total process duration. Each pass scans up to 100 active campaigns and plans a keyset page of 100 users per campaign. Persisted cursors continue later passes. An indexed due queue processes at most 100 targets per pass. Terminal targets leave that due index. Each campaign/user keeps a durable variant assignment. A generic delivery is created only after at least one selected device has a ready target. Recipient discovery continues in bounded keyset rounds, including later audience entrants. Waiting recipients expose a safe reason and recheck deadline, with backoff up to 30 seconds. Fixing traits, credentials or installation facts allows reconciliation. At admission, the selected fanout becomes fixed. Last-active and specific sends never move to another device. All-device sends can finish original slots waiting for credentials, but do not add later devices. Each logical slot points directly to its active immutable target generation. Known-unsent token, credential or content repairs retain the delivery, variant, original expiry and remaining budget. Accepted slots never resubmit. After partial acceptance, configuration repair can finish original slots; incompatible content, variant, goal or selection edits close unsent work. Unknown submission plus identity or content drift closes conservatively instead of making a fresh send. A campaign slot permits three notification-endpoint submissions across all target generations. Pre-message preparation and serving-gate failures do not spend them. Transient failures and unknown submissions retry with backoff, jitter and Retry-After. Each slot exposes its persistent submissionNotBefore timestamp. Consent or serving holds and target generations never lower this retry floor. An incompatible campaign app-ID edit closes original-app slots; it cannot redirect their credentials or fanout to another app. Expiry defaults to 24 hours and cannot be extended through recovery. Campaign end and replacement are terminal. Provider collapse keys do not promise removal of displayed notifications. FCM collapse behavior. The final campaign, audience, consent, binding, token, capability, credential and replacement checks run under the project lock through outcome persistence. The default provider uses a total 10-second cancellation budget, including OAuth, and awaits underlying transport settlement before releasing that lock. The latest campaign end bounds the provider deadline even on direct dispatch. After OAuth, the provider rechecks expiry and calculates FCM TTL at notification invocation. Expired preparation makes no notification call and spends no message opportunity. An injected protocol transport must honor the supplied AbortSignal and settle only when its I/O has stopped. A custom provider must enforce the same bounded settlement contract. Cancellation scheduling and transport cleanup can add time beyond the configured deadline. Already accepted OS notifications cannot be recalled; native routing must reject old binding generations. A cached APNs auth-token rejection or cached FCM 401 permits one fresh-auth retry per slot and credential revision. Every notification invocation has a separate attempt and outcome and spends the same three-submission budget. OAuth exchanges are preparation, not notification submissions. Permanent fresh-auth errors wait for credential repair. This does not prove external credential validity. A slot’s repair requirement remains independent of temporary eligibility holds. Changing consent, pausing a campaign, or restoring capabilities does not retry a permanently rejected credential or unchanged rejected payload. Credential repair requires a new credential revision. Payload repair requires a changed campaign definition or credential revision. Inspection retains the required revision and, for payload repair, the rejected campaign fingerprint until the input changes. An abandoned reservation without an outcome becomes a possible submission (unknown). A worker that proves it never submitted records reservation_expired, distinct from delivery expiry. Process failure after a send can lose acceptance evidence; retrying unknown can duplicate a notification. Exactly-once display is not promised. Receipt observations never manufacture provider acceptance. Frequency limits and quiet-hour policies are not implemented.

Dashboard supervision

The public campaign page separates unique-user totals, device slots, and submission facts. It shows provider uncertainty and unknown receipts explicitly. Push content, destinations, and action labels are read-only. List rows link to push supervision without legacy web/email counts or per-row inspection requests. Use the public dashboard composition to supply the required inspection reader and independent record pagination. Push totals are campaign-wide; per-variant totals are unavailable. Test records can appear in pages while the canonical production totals exclude test work.

Correlated test sends and inspection

Send POST /api/v1/campaigns/{id}/push/test with { installationId, requestId }. Generate and persist a stable request ID before the first call. Retrying the same request returns the same target IDs. A different installation with that request ID conflicts. Read the original test through GET /api/v1/campaigns/{id}/push/tests/{requestId}. A closed serving gate returns 409 before admission; read-back remains 404 and the same request ID can be admitted after the gate opens. Admitted tests perform at most one notification attempt and do not automatically retry. Use a new request ID only when you intend another alert. GET /api/v1/campaigns/{id}/push?page=1&perPage=25 returns bounded pages of recipient controls, logical device slots, immutable targets, append-only attempts/outcomes, observations and conversions. perPage is 1–100 and applies to each collection independently. records contains the full count for each collection and pageCounts its page count. Exact user/device totals cover the whole campaign, not just the page. Items sort by immutable ID. evaluatedAt identifies the read time; separate pages are not a concurrent snapshot. Test targets are separate and never invoke the campaign acceptance hook or convert a campaign. accepted means a positive provider response. receiptObserved means an SDK report. receiptUnknown counts logical slots with positive acceptance or possible submission and no receipt. It excludes known pre-send blocks and permanent rejections without unknown evidence. Neither receipt counter proves visible presentation. confirmedSubmissions, possibleSubmissions, preSendBlocks and pendingOutcomes separate message calls, uncertain calls, preparation blocks and unsettled reservations. Device totals count logical slots; target counts include historical generations. planning counts current recipient states. Positive campaign acceptance transactionally sets the generic delivery’s earliest sentAt and moves queued/sending/retryable state to sent. It preserves later conversion state and never creates deliveredAt or receipt evidence. This user-level projection is distinct from the per-attempt acceptance ledger. Test sends update neither generic campaign delivery statistics nor campaign billing.

Read the evidence in order

For cold-start failures, inspect native launch capture and router/identity confirmation. For foreground silence, inspect the configured suppression policy. For opt-out and account switches, await local restriction and server binding reconciliation; stale interactions must not execute under the new user. Avoid creating new test request IDs merely because an HTTP response was lost. Read back the original request before intentionally sending another alert.

Observation order and conversion

The native envelope is { version: 1, targetId, attemptId, installationId, bindingGeneration, content, test }. APNs carries an object in galinum; FCM carries its JSON string in data.galinum. Use the publishable key and installation capability with POST /api/v1/sdk/installations/{installationId}/observations:
A binding’s sequence starts at 1. Persist tap/action before calling the app handler, then append later events. Batch up to 32 commands. The server rejects sequence gaps, conflicting IDs, old bindings and action IDs absent from the sent payload. Exact replays return the acknowledged position. receipt commands need target/attempt IDs; action also needs actionId. Events require their event name and stable eventId. Optional props preserves the existing JSON object shape, including nested objects, arrays, strings, finite numbers, booleans and null. Its compact JSON size is limited to 4096 bytes. The entire observation body is limited to 65536 encoded bytes. The same project, user, eventId, event name and properties share one business event across this endpoint and /api/v1/track. Reuse that eventId if an ingestion route changes; do not create another ID for fallback. The event ID is project-global: reuse by a different user or with different name/properties returns 409. Queue command id and business eventId serve separate purposes. Properties remain available in event reads and audience targeting. Conversions require a valid tap/action before a target event in server order, including when timestamps are equal. A queued pre-tap event cannot convert when the queue preserves its original order. Normal /api/v1/track requests support a stable eventId for replay. Unsequenced cross-device/offline events use ingestion order: physical occurrence order cannot be recovered without evidence. The SDK must preserve its local queue; no customer-managed causal token or backend identity proof is required. Existing web feedback cannot forge push conversion.

Upgrade and host integration

Existing databases first need the installation upgrade if absent, then the separate packages/server/upgrades/push.sql. Back up and stop API workers before applying each required file with psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 -f. Both use transactions. Fresh databases use canonical schema.sql and skip these one-time upgrades. Do not rerun fresh-volume initialization on existing data. @galinum/push owns providers, payload rules, ordering and delivery. Its host ports cover bounded, indexed transactional push storage, narrow installation access, campaign/recipient projection, encrypted credentials, serving authorization and provider acceptance. The host supplies recordAcceptance(transaction, fact) to write its user-period ledger and outbox in the same transaction. Acceptance IDs provide idempotency; retries and devices must not create duplicate billable users. Self-host has no billing ledger. Cloud integration consumes this implementation separately. Push persistence stores each fact separately. Indexed columns support campaign, user, target, engagement and due-time reads. Inspection aggregates run separately from record pagination; they do not serialize history payloads. Only small queue, clock, cursor and credential control rows are updated. Existing installation storage and its upgrade remain separate from the push upgrade.