Push
APNs + FCM + Web Push, Live Activities (iOS 16.1+), Live Updates (Android FCM data-only), Critical Alerts (iOS entitlement), geofences.
Features
- **APNs + FCM + Web Push (VAPID)** — one SDK, one send API, three transports. Cert + token auth on APNs. Lazy-generated VAPID keypair per-org on first web subscription.
- **Live Activities (iOS ActivityKit) + Live Updates (Android ongoing notifications)** — push-to-update lock-screen widgets. Cross-platform via `pushLiveActivities.platform` discriminator.
- **Geofences (iOS + Android)** — server-defined circular regions; SDK reports enter / exit / dwell back; backend writes `geofence.<event>` rows that Automation workflows can subscribe to.
- **Critical Alerts (iOS)** — `aps.sound={critical:1,name,volume}` + `interruption-level=critical` bypasses DND / Focus / silent (with the Apple entitlement + user permission).
- **Device-takeover on signIn** — anon → identified flip retires the anon `user_id`, reassigns push tokens, hard-deletes the anon row, emits `auth.device_takeover`. No duplicate pushes after login.
- **Per-org delivery policies** — `freqCapPerUserPerDay` / `freqCapPerUserPerHour` (counts non-suppressed rows in window). `quietHoursStartLocal` / `quietHoursEndLocal` (0-23) evaluated against the recipient's `pushTokens.timezone`. Wraps midnight. `deferInQuietHours` → schedule for window end; otherwise → suppress with reason.
- **Sticky-by-user A/B test** — `assignAbVariant(test.id, userId)` hashes to a uniform 0-99 bucket; same user always lands on the same variant. **Honest:** manual winner declaration via `setPushAbTestStatus(winnerKey)`. No automatic statistical readout, no auto-promote.
- **Templates + localization** — `localized_body: { "en": ..., "fr": ... }` resolved per-recipient via `pushTokens.locale` (exact match → 2-letter fallback → default).
- **Rich payloads + action buttons** — `actions: [{ id, title, url? }]` (max 4, APNs limit). Body click → `push.opened`; action click → `push.clicked` w/ action id.
- **Open + click tracking** — SDK fires `POST /push/track-open` on tap; backend stamps `pushSends.openedAt + clickedAt + clickAction` + emits analytics event.
- **Invalid-token pruning** — dispatch errors matching `UNREGISTERED|BadDeviceToken|...` flip `pushTokens.isActive=false` + emit `push.token_invalidated`. Hourly GC cron deletes inactive tokens after 30-day grace.
- **Honest non-features:** no single-call `sendToAudience()` (fan-out via Automation workflow); no automatic Deep-Link auto-wrap on push payloads for ROAS (customer provides URL); no `silent` push convenience param exposed (build via raw payload); A/B test has no statistical-readout engine.
Common use cases
- Replace OneSignal ($139/mo at 10K mobile MAU) + per-platform geofence vendors + Live Activity gateways with one tenant.
- Multi-platform mobile apps that need APNs + FCM + Live Activities + Live Updates without four separate vendors.
- Lifecycle messaging that survives signin — device-takeover collapses anon + identified to one identity, so no duplicate-push storm post-login.
Setup
- 1iOS: upload APNs .p8Dashboard → Apps → iOS app → Push credentials. Sendora stores AES-256-GCM encrypted.
- 2Android: upload FCM service-account JSONDashboard → Apps → Android app → Push credentials. Encrypted at rest; Sendora extracts FCM project ID server-side.
- 3iOS Critical Alerts (optional)Apple developer entitlement required. Apply via developer.apple.com support form (1-3 weeks). Use for safety / emergency only.
Push
Register a device token
FREECall after the OS grants notification permission. APNs (iOS) + FCM (Android) + Web Push (RFC 8030 / VAPID) supported. Token bound to current Auth user (or anonymous user). Note: Sendora does not speak Expo Push Service — Expo managed-workflow apps must eject to bare or use a webhook bridge to expo-server-sdk.
// Service worker registered at /sw.js handles "push" + "notificationclick".
await sendora.webPush.subscribe("/sw.js");Send a push notification (direct)
FREEServer-side direct send. Org-scoped path. Audience-resolution + per-recipient TZ-aware quiet hours + frequency cap + A/B variant + locale resolution all applied. Pass `userIds`, `audienceId`, OR raw `tokens` (mutually exclusive). Failed-delivery tokens auto-pruned.
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/send \
-H "x-api-key: pk_prod_…" \
-d '{
"userIds": ["u_abc123"],
"title": "Order shipped",
"body": "Your package is on the way",
"data": { "orderId": "ord_42", "url": "/orders/42" }
}'Broadcast to an audience
STARTER+Same endpoint as direct send — pass `audienceId` instead of `userIds`. Audience evaluation happens at send time. Quiet hours + frequency caps + localization apply per recipient.
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/send \
-H "x-api-key: pk_prod_…" \
-d '{
"audienceId": "<AUDIENCE_UUID>",
"title": "Sale starts now",
"body": "20% off everything",
"localizedBody": { "en": "20% off everything", "fr": "20% sur tout", "es-MX": "20% en todo" }
}'Schedule a send
STARTER+Pass `scheduledFor` (ISO timestamp). 30s poller claims due rows. Cancel via DELETE on the returned send id while still `status=scheduled`.
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/send \
-H "x-api-key: pk_prod_…" \
-d '{
"audienceId": "<AUDIENCE_UUID>",
"title": "Daily digest",
"body": "Today\u2019s top stories",
"scheduledFor": "2026-05-09T08:00:00Z"
}'Track a notification open / click
FREESDKs auto-fire this on tap. Stamps `pushSends.openedAt + clickedAt + clickAction` and emits `push.opened` / `push.clicked` analytics events for workflow triggers.
curl -X POST https://api.sendoracloud.com/api/v1/push/track-open \
-H "x-api-key: pk_prod_…" \
-d '{ "sendId": "<SEND_UUID>", "clickAction": "save" }'Per-org delivery policies (quiet hours + frequency cap)
FREEGET / PATCH org-level push policies. Quiet hours evaluated per-recipient against `pushTokens.timezone` (IANA, DST-correct, wraps midnight). Frequency cap counts non-suppressed sends per user in rolling window. ADMIN role on PATCH.
# Read
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/settings \
-H "x-api-key: pk_prod_…"
# Update
curl -X PATCH https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/settings \
-H "x-api-key: pk_prod_…" \
-d '{
"quietHoursStartLocal": 22,
"quietHoursEndLocal": 7,
"deferInQuietHours": true,
"freqCapPerUserPerDay": 5,
"freqCapPerUserPerHour": 2
}'Query send history + delivery health
FREEList sends with status filter, aggregate stats, last-7d delivery-health (top failure / suppression reasons + recent raw failures). Use this instead of guessing whether your sends actually delivered.
# List (paginated)
curl 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends?status=failed&pageSize=20' \
-H "x-api-key: pk_prod_…"
# Aggregate counts (total/sent/delivered/failed/scheduled/suppressed/opened/clicked + rates)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends/stats \
-H "x-api-key: pk_prod_…"
# Delivery health — top failure / suppression reasons + last-10 failures
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends/delivery-health \
-H "x-api-key: pk_prod_…"Templates + A/B tests
STARTER+Saved drafts (templates) + sticky-by-user variant tests (A/B). Reference at send-time via `templateId` or `abTestId`. Per-variant counts via `/ab-tests/:id/stats`.
# Templates CRUD
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/templates \
-H "x-api-key: pk_prod_…" \
-d '{ "name": "welcome", "title": "Welcome!", "body": "Tap to start", "actions": [{"id":"start","title":"Start"}] }'
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/templates \
-H "x-api-key: pk_prod_…"
# A/B tests — variants stored as jsonb, weights sum to 100
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/ab-tests \
-H "x-api-key: pk_prod_…" \
-d '{
"name": "subject-line-test",
"status": "active",
"variants": [
{ "key": "A", "title": "20% off everything", "body": "Today only", "weight": 50 },
{ "key": "B", "title": "Last chance — 20% off", "body": "Ends midnight", "weight": 50 }
]
}'
# Per-variant stats
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/ab-tests/<TEST_UUID>/stats \
-H "x-api-key: pk_prod_…"Live Activities (iOS) / Live Updates (Android)
GROWTH+ActivityKit on iOS (push-type=liveactivity), data-only FCM push routed to NotificationCompat on Android. Host app starts the activity locally; SDK registers per-activity token; server-side push updates the visual state.
# Server-side update (existing activity from POST /push/live-activities/start-token)
curl -X PATCH https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/live-activities/<ACTIVITY_UUID>/update \
-H "x-api-key: pk_prod_…" \
-d '{
"contentState": { "status": "delivered", "minutesAway": 0 },
"alert": { "title": "Order delivered", "body": "Enjoy!" }
}'
# List active activities for org
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/live-activities \
-H "x-api-key: pk_prod_…"
# End
curl -X DELETE https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/live-activities/<ACTIVITY_UUID> \
-H "x-api-key: pk_prod_…"Geofences
GROWTH+Server-managed circular regions. CRUD by operator; SDK auto-fetches + arms (iOS cap 20 via CLLocationManager, Android cap 100 via GeofencingClient). Enter/exit/dwell transitions emit `geofence.entered` / `.exited` / `.dwelled` events.
# Create
curl -X POST https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/geofences \
-H "x-api-key: pk_prod_…" \
-d '{
"name": "Downtown SF",
"latitude": 37.7749,
"longitude": -122.4194,
"radiusMeters": 500,
"triggers": ["enter", "exit"],
"priority": 10,
"enabled": true
}'
# List
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/geofences -H "x-api-key: pk_prod_…"
# Per-geofence trigger counts (last 30d)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/geofences/<GEOFENCE_UUID>/stats \
-H "x-api-key: pk_prod_…"Configure APNs / FCM / VAPID
FREEUpload APNs `.p8` auth key + key id + team id; paste FCM v1 service-account JSON; VAPID keypair lazy-generated per org on first /push/vapid call. Per-org credentials stored AES-256-GCM at rest.
# Configure in dashboard → Push → Settings → Providers. Encrypted server-side.
# Required before any send will deliver.
# Web Push: GET /orgs/:orgId/push/vapid returns the public key for browser subscribe().