Skip to main content

The push channel model

When you send a push through Orbit, one API call fans out across four transports — APNs for iOS, FCM for Android, Huawei Push Kit (HMS), and Web Push (VAPID) — delivered through the device registry your clients build up over time. This page explains that model end to end: what a push-capable device is, which capability flags travel with a send, how targeting resolves to device rows and how partial failures read back, scheduled sends, and the separate VoIP registry used to ring agents on inbound calls. The Push channel guide walks through the API surface with copy-pasteable requests; this page is the model behind it. It is also deliberately separate from the notification model: that page covers operator alerts inside your own workspace, while this one covers pushes you send to your end users’ devices.

Section 1 — What a push-capable device is

A push-capable device is a row in the device registry, created when your client SDK calls POST /api/v1/push/device-tokens (the older POST /api/v1/push/register alias behaves identically). The platform picked determines what the client supplies:
  • iOS (ios) and Android (android) — the native APNs or FCM registration token.
  • Huawei (huawei) — the HMS Push Kit registration token.
  • Web (web) — the browser’s PushSubscription.toJSON() envelope (endpoint URL plus the p256dh and auth keys), which Orbit stores as one canonical token. Web push subscribers fetch the VAPID public key from GET /api/v1/push/config before subscribing. Subscription endpoints must be publicly reachable URLs; a browser endpoint that resolves to an internal address is rejected at registration.
Each successful registration returns a device id plus the canonical token, and an optional user_id ties the device to an end-user identity. If you omit user_id, the authenticated caller is used. That link is what makes user-level targeting work: sending to user_ids resolves to every registered device that user owns, so an iPhone, an iPad, and a browser all ring from one call. GET /api/v1/push/device-tokens lists the registry (optionally filtered by user). Two registry behaviors matter for day-2 operation. Re-registering an existing token updates the row instead of duplicating it, and supplying app_install_id on registration retires the install’s older tokens — the mechanism that keeps a reinstalled app from accumulating stale rows.

Section 2 — Capability flags

Every send carries a title and body (plus a small data map your app reads on tap), and Orbit keeps the payload under the ceiling APNs and FCM enforce: a request whose total payload exceeds roughly 3.8 KB is rejected with a validation error rather than partially delivered. On top of the body, individual capability flags shape how the OS presents the notification:
  • badge — the iOS Lock Screen badge number, stored on the notification record so GET /api/v1/push/notifications can report it.
  • mutable_content — lets the iOS Notification Service Extension rewrite the payload before display; required for rich media handling on the device.
  • thread_id — groups related notifications together on iOS.
  • interruption_level / relevance_score — iOS presentation urgency and ordering hints.
  • apns_priority (1, 5, or 10), apns_collapse_id, and apns_expiration — APNs delivery priority, coalescing, and expiry.
  • actions — up to four in-notification buttons (for example “Pay now” / “Dismiss”).
Silent push means a push that carries no visible alert. The classic use is badge hygiene: POST /api/v1/push/badge/clear (optionally with a user_id) sends a background push with badge: 0 to refresh the icon without showing a banner. Silent pushes are also how VoIP and background-sync payloads travel — the payload is data-only, and the device handles it without a banner. Per-platform overrides (ios, android, webpush objects on the send) let a single request set platform-specific presentation without splitting into one request per transport.

Section 3 — Targeting model

A send must name a target, and there are three forms:
  1. device_token_ids — explicit registry ids. Use it when you already know exactly which devices should get the notification.
  2. user_ids — user-level fan-out, resolved to every registered device those users own. One recipient equals one frequency-cap decision, so a user with several devices consumes a single cap slot and either all of their devices receive the push or none are partially capped.
  3. Broadcast (user_ids: ["*"]) — every deliverable device in the tenant, capped at 100,000 devices. A broadcast above the limit is rejected with a BROADCAST_TOO_LARGE validation error so you split it into explicit user lists instead of silently truncating the audience.
All three forms screen targets at send time: devices that opted out (a suppression entry for the user’s address on the push channel, or channel-wide) are excluded server-side, and devices an operator paused stay registered but are skipped. When no target resolves to a device, the send returns a NO_DEVICE_TOKENS validation error. Two optional fields change how the notification is classified on the device:
  • notification_channel — names a category registered through POST /api/v1/push/categories. A category bundles an identifier (wire-bound, so the iOS/Android client matches it), an optional sound, and action buttons. Naming one on a send expands it into the APNs category and becomes the Android channel_id default — so interactive buttons and notification settings respect the same label on both platforms. Category records are immutable post-create; the supported update pattern is delete and recreate.
  • deep_link — passed through to the client verbatim; on tap your app receives it and routes to the right screen.

Section 4 — Delivery and per-device results

The send response is per-device by design. It returns a notifications array with one row per targeted device — the notification id, the device id, the transport actually used (apns, fcm, webpush, or hms), a status of sent, failed, or skipped, and an error when that device did not deliver — plus the totals (total, sent, devices_targeted). A skipped row with a frequency-cap message tells you exactly which recipient hit your configured cap and when to retry. Partial failure is expected: one dead device never fails the batch, and the same per-device outcome is mirrored into the tenant message log so the Messages page shows push alongside every other channel. Every outcome is also emitted to webhook subscribers as a push.sent or push.failed event per device, and engagement continues after the send. The client SDK calls POST /api/v1/push/notifications/:id/ack with event: "delivered" when the device receives the push and event: "opened" when the user taps it. The ack is idempotent — a repeated ack does not re-fire — and the matching push.delivered / push.opened webhook emits only on the first genuine transition. GET /api/v1/push/notifications pages the full log with delivered and opened timestamps for reconciliation. Because silent and background pushes can wake a device, the SDK also reports device-side telemetry (POST /api/v1/push/telemetry — battery level, wake count, delivery latency), aggregated per user and platform at GET /api/v1/push/telemetry/summary. That aggregate is the honest check on background push: median battery level at push wake plus average wake count, so you can answer “is background push draining our users’ devices?” with a number.

Section 5 — Scheduled push

Supplying a future send_at on POST /api/v1/push/send schedules the notification instead of dispatching it. A strictly-future timestamp persists the full payload, returns 202 with the scheduled push id, and fires a push.queued webhook; at send time the scheduler replays that exact payload back through the send path, so suppression, frequency caps, and the multi-transport fan-out apply identically to scheduled and immediate sends. A send_at that is at or before now falls through to an immediate send rather than being silently dropped to clock skew. Manage the queue with GET /api/v1/push/scheduled (optionally filtered by status) and GET /api/v1/push/scheduled/:id. DELETE /api/v1/push/scheduled/:id cancels a still-queued push, but cancel is a guarded status change: a push the scheduler already dispatched or failed returns a conflict so the caller knows it has already left — never a silent no-op. For time-of-day policy, pair send_at with the tenant-owned controls in Send gating and quiet hours; scheduling makes the send land inside the window rather than postponing it by hand.

Section 6 — VoIP push

VoIP push is a second, separate registry — not the device registry above — and it exists because an inbound call has a different delivery contract than a notification. When the ACD queue offers a call to an agent, the ring path fans the call out to the agent’s VoIP devices so a locked or force-backgrounded phone rings with the native incoming-call UI (CallKit on iOS, ConnectionService on Android) instead of a passive banner. The differences from notification push are deliberate:
  • Separate registry and transports. Agents register PushKit (voip) tokens on iOS or high-priority FCM tokens on Android through the VoIP device endpoints (POST /push/voip-devices, with list and unregister siblings). Notification tokens and VoIP tokens are owned by different platform APIs, so mixing them into one registry would misroute one or the other.
  • Data-only payload with a bounded TTL. The payload carries the call id, caller id and name, the queue name, a reservation token, and a time-to-live, sent at APNs voip priority / FCM high priority. The TTL is clamped to a 5–120 second window (default 30) so the push dies with the ring window instead of ringing a phone after the caller already rolled to the next agent.
  • Fail-open. Zero registered VoIP devices or unconfigured push credentials degrade the dispatcher without stalling the ring cycle — the queue’s existing hunt and no-answer logic keeps working.
  • Existing call endpoints own accept and decline. Answering or declining routes through the same call endpoints the softphone uses, keyed on the call id the push carries — there is no parallel resolve endpoint to diverge from the call’s lifecycle.

Section 7 — Token hygiene

Push providers report a dead token by name (for example APNs BadDeviceToken, FCM UNREGISTERED, or a Web Push endpoint that is gone). Orbit treats those permanent-failure signals automatically:
  • On the notification registry, a permanently dead token is deleted in-line during the send, so the next send does not repeat the failure. Transient errors (timeouts, rate limits) never trigger cleanup.
  • On the VoIP registry, a dead token is marked revoked but the row is kept, so an operator can see why an agent’s device stopped ringing rather than watch it vanish.
  • Prefer pausing over deleting when the device is still valid: PATCH /api/v1/push/device-tokens/:id with enabled: false skips the device on every send while keeping the token on file; DELETE /api/v1/push/device-tokens/:id removes it outright (for example on sign-out).
Registration-side hygiene — the re-register upsert and the app_install_id reinstall dedup from Section 1 — handles the other half: browsers rotate web push subscriptions and mobile OSes renew native tokens, and both paths converge into a single live row per install.

Where this fits

  • Push — the channel guide: setup, sample requests, and the send surface.
  • The notification model — operator-facing alerts (the dashboard bell, email, and digests), a separate system from pushed-to-customer notifications.
  • Send gating and quiet hours — tenant-owned timing policy that scheduled send_at pairs with.
  • Consent and suppression — the suppression entries the send path screens every target against.
  • Webhook fan-out and event sinks — subscribing to the push.queued / push.sent / push.failed / push.delivered / push.opened lifecycle.