Skip to content
Sendora Cloud
Create account
Module deep-dive

Push notifications

APNs HTTP/2 + JWT, FCM v1 OAuth2, Web Push (RFC 8030 / VAPID). Live Activities (iOS), Live Updates (Android), Critical Alerts, geofences, A/B variants, templates, scheduling, IANA-TZ quiet hours, frequency caps, locale-resolved body, action buttons, open + click tracking.

End-to-end flow

  1. 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 first GET /push/vapid call. Credentials encrypted AES-256-GCM at rest.
  2. Register device tokens from your app via the SDK — APNs token (iOS), FCM token (Android), orpushManager.subscribe() parts (Web). SDK posts to POST /api/v1/push/tokens; org auto-derived from the API key. userId binds the token to a person once your app calls identify().
  3. Send via direct API (POST /orgs/:orgId/push/send) or via workflow send_push step. Both routes apply quiet hours, frequency caps, A/B variants, locale resolution.
  4. Track + react: SDK fires POST /push/track-open on tap; backend stamps the row + emits push.opened / push.clicked events that workflows can branch on.

Provider credentials

iOS — APNs token-based auth (.p8)

  1. developer.apple.com → Keys → Create a Key. Tick "Apple Push Notifications service (APNs)." Download the .p8 file. Apple lets you download it once — store it securely.
  2. Note the Key ID (10-char) and your Team ID (top-right of developer portal).
  3. 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 BadDeviceToken on 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.environment column + 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

  1. console.firebase.google.com → Project Settings → Service accounts → Generate new private key. Download the JSON.
  2. 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 webhook step instead of send_push. Sendora signs the request (HMAC-SHA256 in X-Sendora-Signature); your server looks up the recipient's Expo token + calls expo-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 → status scheduled + scheduledFor=next window end. false → status suppressed with suppressedReason: "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 as platform=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's google-services.json.
  • quiet_hours / frequency_cap in suppressedReason — 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 until scheduledFor.
  • 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