End-to-end flow
- Configure providers in Dashboard → Push → Settings: APNs
.p8+ Team ID + Key ID + bundle ID; FCM v1 service-account JSON; Web Push VAPID auto-generates on firstGET /push/vapidcall. Credentials encrypted AES-256-GCM at rest. - Register device tokens from your app via the SDK — APNs token (iOS), FCM token (Android), or
pushManager.subscribe()parts (Web). SDK posts toPOST /api/v1/push/tokens; org auto-derived from the API key.userIdbinds the token to a person once your app callsidentify(). - Send via direct API (
POST /orgs/:orgId/push/send) or via workflowsend_pushstep. Both routes apply quiet hours, frequency caps, A/B variants, locale resolution. - Track + react: SDK fires
POST /push/track-openon tap; backend stamps the row + emitspush.opened/push.clickedevents that workflows can branch on.
Provider credentials
iOS — APNs token-based auth (.p8)
developer.apple.com → Keys → Create a Key. Tick "Apple Push Notifications service (APNs)." Download the.p8file. Apple lets you download it once — store it securely.- Note the Key ID (10-char) and your Team ID (top-right of developer portal).
- Dashboard → Push → Settings → iOS providers → paste contents + Key ID + Team ID + bundle ID. Sendora generates JWTs every 50 minutes (Apple caps validity at 60).
APNs environment — sandbox vs production
.p8 auth keys aren't environment-specific (the same key signs for both endpoints), but APNs tokens are. Tokens minted by a Debug-provisioned build only resolve at api.sandbox.push.apple.com; Release/AdHoc tokens only resolve at api.push.apple.com.
- Sendora today dispatches every iOS token to APNs production. Production builds (App Store, TestFlight) work as expected.
- Debug-provisioned builds (Xcode Run, Xcode direct-to-device, Xcode Simulator on physical-device builds) return tokens that error
BadDeviceTokenon dispatch — by design, not a bug. Use the Send test push from dashboard against a TestFlight / production build during development. - Per-token sandbox routing is on the roadmap (will derive env from a new
pushTokens.environmentcolumn + dispatch to the matching endpoint).
Most common cause of BadDeviceToken in production: bundle ID mismatch between your APNs.p8 key's app and the app actually installed on the device. Verify the bundle id in dashboard → Push → Settings matches your built target.
Android — FCM v1 service account JSON
console.firebase.google.com → Project Settings → Service accounts → Generate new private key. Download the JSON.- Dashboard → Push → Settings → Android providers → paste full JSON.
Web — VAPID
No upload needed. First call to GET /api/v1/orgs/:orgId/push/vapid lazy-generates a P-256 keypair stored server-side. Public half is what you pass to pushManager.subscribe({applicationServerKey}); private stays server-side for signing JWTs to web push services.
Register a device token
// In application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
let token = deviceTokenData.map { String(format: "%02x", $0) }.joined()
SendoraCloud.push?.registerToken(token, platform: .ios) { _ in }React Native — Expo managed workflow gotcha
Sendora dispatches via APNs / FCM / Web Push directly. It does NOT speak Expo Push Service (exp.host/--/api/v2/push/send). If your app is on Expo managed workflow and you only have ExponentPushToken[xxx], those tokens cannot be dispatched — APNs rejects with BadDeviceToken, FCM rejects with InvalidRegistration, and the send row stamps status=failed.
Two paths:
- Eject to bare workflow — install
@react-native-firebase/messaging+ native iOS push capability. Real APNs / FCM tokens unlock the full Sendora feature set (Live Activities, Critical Alerts, Geofences, etc.). - Webhook bridge (managed-friendly) — keep Expo tokens; the workflow rule fires a
webhookstep instead ofsend_push. Sendora signs the request (HMAC-SHA256 inX-Sendora-Signature); your server looks up the recipient's Expo token + callsexpo-server-sdk.
Webhook bridge — concrete example
1. Workflow definition — webhook step (not send_push):
{
"name": "Sports score push (Expo bridge)",
"trigger": { "eventType": "score.changed" },
"isActive": true,
"steps": [
{
"type": "webhook",
"config": {
"url": "https://notifier.pulsenews.io/sendora/push",
"method": "POST",
"body": {
"userId": "{{event.userId}}",
"title": "{{event.team}} scored",
"body": "{{event.player}} — {{event.score}}",
"data": { "matchId": "{{event.matchId}}" }
}
}
}
]
}2. Bridge endpoint on your server (Hetzner / Vercel / wherever):
// Node + Express + expo-server-sdk — production-grade bridge.
import express from "express";
import { Expo } from "expo-server-sdk";
import crypto from "node:crypto";
import { lookupUser, logBridgeEvent } from "./db";
const app = express();
const expo = new Expo();
const SENDORA_WEBHOOK_SECRET = process.env.SENDORA_WEBHOOK_SECRET!;
const TIMESTAMP_TOLERANCE_SEC = 300; // 5 min — matches Sendora's signing window
app.post("/sendora/push",
// Capture raw body for HMAC verification — never JSON-parse before verify.
express.raw({ type: "*/*" }),
async (req, res) => {
const eventId = req.header("x-sendora-event-id") ?? "unknown";
// 1. Content-Type guard. Sendora workflow webhook step defaults to JSON
// when body is an object (which it is for our send_push payloads).
// Reject anything else so we never JSON.parse a form-encoded body.
const contentType = (req.header("content-type") ?? "").toLowerCase();
if (!contentType.startsWith("application/json")) {
logBridgeEvent({ eventId, drop: "bad_content_type", contentType });
return res.status(415).send();
}
// 2. Replay protection. Reject if dispatch timestamp is > 5 min skew.
// Run NTP on this host or you'll false-reject when subscriber clock drifts.
const tsHeader = req.header("x-sendora-timestamp");
const ts = tsHeader ? Number(tsHeader) : NaN;
if (!Number.isFinite(ts) || Math.abs(Math.floor(Date.now() / 1000) - ts) > TIMESTAMP_TOLERANCE_SEC) {
logBridgeEvent({ eventId, drop: "stale_or_missing_timestamp", tsHeader });
return res.status(401).send();
}
// 3. HMAC verify. timingSafeEqual needs equal-length Buffers.
// Express normalises header names to lowercase; spec capitalises.
const sig = req.header("x-sendora-signature") ?? "";
const expected = crypto
.createHmac("sha256", SENDORA_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const sigBuf = Buffer.from(sig);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
logBridgeEvent({ eventId, drop: "hmac_mismatch" });
return res.status(401).send();
}
// 4. Parse — safe now that body is verified.
let payload: { userId?: string; title?: string; body?: string; data?: unknown };
try {
payload = JSON.parse(req.body.toString());
} catch {
logBridgeEvent({ eventId, drop: "json_parse_error" });
return res.status(400).send();
}
const { userId, title, body, data } = payload;
if (!userId || !title || !body) {
logBridgeEvent({ eventId, drop: "missing_fields" });
return res.status(400).send();
}
// 5. Resolve Expo token from your own user table.
const user = await lookupUser(userId);
if (!user?.expo_push_token || !Expo.isExpoPushToken(user.expo_push_token)) {
// Log silent drops so dashboard "delivered" can be reconciled.
logBridgeEvent({ eventId, userId, drop: "no_expo_token" });
return res.status(204).send();
}
// 6. Hand off to expo-server-sdk.
try {
const tickets = await expo.sendPushNotificationsAsync([
{ to: user.expo_push_token, title, body, data },
]);
logBridgeEvent({ eventId, userId, dispatched: true, ticketIds: tickets.map(t => t.status === "ok" ? t.id : t.message) });
res.status(200).send();
} catch (err) {
logBridgeEvent({ eventId, userId, drop: "expo_dispatch_error", err: String(err) });
// Return 5xx so Sendora retries per the documented schedule (30s/2m/10m/1h/6h).
res.status(502).send();
}
}
);What the bridge gives up vs native dispatch: per-org quiet hours, frequency caps, A/B variants, locale resolution, Live Activities, Critical Alerts. You can replicate quiet hours + freq cap in the bridge itself. Live Activities are iOS-native-only — bare workflow required.
Quiet hours + frequency caps
Per-org policies. PATCH at /orgs/:orgId/push/settings (ADMIN role).
curl -X PATCH https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/settings \
-H "x-api-key: sk_prod_..." \
-d '{
"quietHoursStartLocal": 22,
"quietHoursEndLocal": 7,
"deferInQuietHours": true,
"freqCapPerUserPerDay": 5,
"freqCapPerUserPerHour": 2
}'- Quiet hours evaluated per-recipient against
pushTokens.timezone(IANA, DST-correct). Window wraps midnight when end < start.deferInQuietHours: true→ statusscheduled+scheduledFor=next window end.false→ statussuppressedwithsuppressedReason: "quiet_hours". - Frequency cap counts non-suppressed sends per user in rolling window. At-or-above cap → status
suppressed,suppressedReason: "frequency_cap".
Verify delivery
Push dispatch failures don't bubble up as HTTP errors —POST /push/send returns 200 even if APNs / FCM rejects per token. Always check pushSends.status + pushSends.error:
# Aggregate (last 30d)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends/stats \
-H "x-api-key: sk_prod_..."
# Delivery health (last 7d top reasons + last-10 raw failures)
curl https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends/delivery-health \
-H "x-api-key: sk_prod_..."
# Filter
curl 'https://api.sendoracloud.com/api/v1/orgs/<ORG_UUID>/push/sends?status=failed&pageSize=20' \
-H "x-api-key: sk_prod_..."Common first-time failures:
BadDeviceToken— APNs sandbox vs production mismatch, or registering an Expo token asplatform=ios.Unregistered/InvalidRegistration— token rotated or app uninstalled. Auto-pruned to inactive.MismatchSenderId(FCM) — service account JSON belongs to a different Firebase project than the app'sgoogle-services.json.quiet_hours/frequency_capinsuppressedReason— policy blocked the send. Tune via/push/settings.
Webhook events
Subscribe via POST /orgs/:orgId/webhooks with events: [...]. All events HMAC-SHA256-signed in X-Sendora-Signature. Full canonical enum lives at /docs/api → Webhook event catalog. Push-relevant subset:
push.send_requested— accepted into queue.push.scheduled— deferred untilscheduledFor.push.sent— dispatched to APNs / FCM / Web Push.push.delivered— provider receipt confirmed.push.failed— dispatch error stamped on row.push.suppressed— blocked by quiet hours / freq cap / consent.push.cancelled— `DELETE /push/sends/:id` while still scheduled.push.opened— recipient tapped the notification.push.clicked— recipient tapped a specific action button.push.token_invalidated— APNs / FCM returned Unregistered. Reconcile your local token table.push.live_activity_started/_updated/_ended/_invalidated.geofence.entered/.exited/.dwelled.
More
- All push endpoints in API reference.
- Quickstart §6 — first push.
- RN / Expo callout.
- Live Activities + Live Updates deep-dive (ActivityKit + ProgressStyle).