> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Push Notifications

> Send mobile + web push notifications via Orbit

# Push Notifications

Orbit's Push channel delivers notifications to iOS (APNs), Android (FCM), Huawei (HMS Push Kit), and Web Push (VAPID) subscribers from a single API surface. Token registration, capability flags (sound, badge, mutable-content), and per-device delivery results are surfaced through the same Messaging API used for every other channel.

## Send a push

`POST /api/v1/push/send` fans a single notification out across every registered
iOS / Android / Web device that matches the targets you supply.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/push/send \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_ids": ["user_8a1f2c"],
    "title": "New order",
    "body": "Order #12345 is ready for pickup",
    "data": {
      "order_id": "12345"
    },
    "image_url": "https://cdn.example.com/orders/12345.png",
    "deep_link": "myapp://orders/12345",
    "badge": 1,
    "notification_channel": "orders",
    "mutable_content": true,
    "interruption_level": "active",
    "apns_priority": 10,
    "actions": [
      { "id": "view", "title": "View order" },
      { "id": "dismiss", "title": "Dismiss", "destructive": true }
    ],
    "ios": {
      "sound": "default",
      "thread_id": "orders"
    },
    "android": {
      "channel_id": "orders",
      "priority": "high"
    },
    "webpush": {
      "icon_url": "https://cdn.example.com/icon.png",
      "require_interaction": true
    }
  }'
```

### Targeting

There is **no `to` / `from` / `topic` field** — provide **exactly one** of:

* **`device_token_ids`** — array of registered device-token ids to deliver to
  specific devices.
* **`user_ids`** — array of Orbit user ids; pass `["*"]` to **broadcast** to
  every opted-in device in the tenant (subject to the broadcast recipient cap).

If neither is present the request fails validation with HTTP `422`
(`Either device_token_ids or user_ids must be provided`). STOP / opt-out and
disabled tokens are filtered server-side, so a broadcast only reaches
deliverable devices.

### Fields

`title` (1–500 chars) and `body` (1–4000 chars) are required; values outside
those bounds are rejected with HTTP `422`. Top-level rich-media and per-platform
fields:

| Field                  | Type                  | Notes                                                                    |
| ---------------------- | --------------------- | ------------------------------------------------------------------------ |
| `data`                 | object                | Custom key/value payload (JSON, \< 4 KB).                                |
| `image_url`            | string (https)        | Notification image (APNs / FCM / Web).                                   |
| `deep_link`            | string (≤ 2048 chars) | In-app destination opened on tap.                                        |
| `badge`                | integer ≥ 0           | iOS lock-screen badge applied to all targets.                            |
| `notification_channel` | string (≤ 64 chars)   | Notification category; also the default Android FCM `channel_id`.        |
| `mutable_content`      | boolean               | Enables iOS Notification Service Extension.                              |
| `interruption_level`   | enum                  | `passive` · `active` · `time-sensitive` · `critical`.                    |
| `relevance_score`      | number 0–1            | iOS notification summary ranking.                                        |
| `thread_id`            | string (≤ 64 chars)   | iOS grouping thread.                                                     |
| `apns_priority`        | `1` · `5` · `10`      | APNs delivery priority.                                                  |
| `actions`              | array (≤ 4)           | Buttons `{ id, title, destructive? }`; `id` and `title` ≤ 64 chars each. |
| `send_at`              | ISO-8601              | Schedule for a future instant (see below).                               |

Over-length or malformed values fail schema validation with HTTP `422`; the
offending field path is returned in `error.details`.

Per-platform override objects fine-tune a single platform:

* **`ios`** — `sound`, `badge`, `category`, `thread_id`, `interruption_level`,
  `relevance_score`, `mutable_content`, `image_url`, `apns_collapse_id`,
  `apns_priority`, `apns_expiration`.
* **`android`** — `channel_id`, `priority` (`normal` · `high`), `ttl_seconds`,
  `image_url`, `notification_count`, `color` (`#RRGGBB`).
* **`webpush`** — `icon_url`, `image_url`, `actions` (`{ action, title }`, at
  most **2**, each string ≤ 64 chars), `urgency`, `ttl_seconds`, `topic`
  (must match `^[A-Za-z0-9_-]{1,32}$` — letters, digits, `_` or `-`, 1–32 chars),
  `require_interaction`, `tag` (≤ 64 chars). A `topic` with disallowed
  characters or more than 2 `actions` is rejected with HTTP `422`.

### Scheduled send

Supply `send_at` (ISO-8601 with offset) to schedule the send. When it is
strictly in the future the request returns `202` and the payload is replayed at
send time; a value at or before now falls through to an immediate send.

### Response

An immediate send returns `201` with a per-device result list. Each `id` is a
push-notification id (`push_` + 32 lowercase hex chars) and `deviceTokenId` is a
registered device-token id (`dtk_` + 32 lowercase hex chars):

```json theme={null}
{
  "data": {
    "notifications": [
      {
        "id": "push_3f9c1a2b4d6e8f0a1b2c3d4e5f60718a",
        "deviceTokenId": "dtk_9f2c1a0b3d4e5f60718293a4b5c6d7e8",
        "status": "sent"
      }
    ],
    "total": 1,
    "sent": 1,
    "devices_targeted": 1
  },
  "meta": {
    "request_id": "req_xyz789",
    "timestamp": "2026-05-09T00:00:00Z"
  }
}
```

A scheduled send (`send_at` in the future) returns `202` with a scheduled-push
id (`spush_` + 32 lowercase hex chars):

```json theme={null}
{
  "data": {
    "id": "spush_7a1f2c3d4e5b6a7980c1d2e3f4a5b6c7",
    "status": "scheduled",
    "send_at": "2026-05-09T12:00:00Z"
  },
  "meta": {
    "request_id": "req_xyz789",
    "timestamp": "2026-05-09T00:00:00Z"
  }
}
```

## Capabilities

* **Multi-platform** — APNs (iOS, macOS, watchOS, tvOS, visionOS), FCM (Android), HMS Push Kit (Huawei devices without Google Play Services), and Web Push (VAPID).
* **Device tokens or users** — target explicit `device_token_ids`, a set of `user_ids`, or broadcast to every opted-in device with `user_ids: ["*"]`.
* **Rich content** — title, body, image, action buttons, deep-link data payload, badge, sound, channel id (Android), thread id (iOS).
* **Custom data payload** — attach a `data` object (\< 4 KB) that rides alongside the notification so your app can act on tap or on receipt.
* **Live Activities** (iOS 16.1+) — start, update, and end Live Activity notifications with the activity-specific endpoint variant.
* **Delivery + open events** — `push.delivered` and `push.opened` webhook events when SDK is integrated.
* **Auto-token rotation on GKE** — FCM service-account tokens are auto-refreshed via the GCE metadata server; no manual key management required.

## Apple Push setup (APNs)

Native iOS push uses **token-based (`.p8`) authentication**. The platform signs
an ES256 JWT from your APNs Auth Key on every delivery, so you configure four
credentials on the API / push worker via environment.

1. **Create an APNs Auth Key** — in the [Apple Developer portal](https://developer.apple.com/account/resources/authkeys/list),
   go to **Certificates, Identifiers & Profiles → Keys**, create a key with the
   **Apple Push Notifications service (APNs)** capability, and download the
   `AuthKey_<KEY_ID>.p8` file. Apple lets you download it **once** — store it in
   your secret manager immediately.

2. **Configure the environment** — set all four variables. `DEVOTEL_APNS_KEY_ID`,
   `DEVOTEL_APNS_TEAM_ID`, and `DEVOTEL_APNS_PRIVATE_KEY` must be set **together**;
   if any is missing every targeted iOS device returns `status: "failed"`.

   | Variable                   | Required  | Where to source it                                                                                                                                                                                                                                                                                                                                                       |
   | -------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
   | `DEVOTEL_APNS_KEY_ID`      | yes (iOS) | The 10-character Key ID shown next to the Auth Key under **Keys** when you created it.                                                                                                                                                                                                                                                                                   |
   | `DEVOTEL_APNS_TEAM_ID`     | yes (iOS) | Your 10-character Apple Developer Team ID, on the **Membership** page.                                                                                                                                                                                                                                                                                                   |
   | `DEVOTEL_APNS_PRIVATE_KEY` | yes (iOS) | The full contents of the `AuthKey_<KEY_ID>.p8` file, including the `-----BEGIN PRIVATE KEY-----` / `-----END PRIVATE KEY-----` lines. **Secret** — provision via your secret store, never commit it.                                                                                                                                                                     |
   | `DEVOTEL_APNS_BUNDLE_ID`   | yes (iOS) | Your app's bundle identifier (e.g. `io.devotel.orbit`); sent as the APNs `apns-topic`.                                                                                                                                                                                                                                                                                   |
   | `DEVOTEL_APNS_PRODUCTION`  | no        | Forces the production APNs host (`api.push.apple.com`) when set to `true`. **This variable only takes effect under a non-production `NODE_ENV`** — a deployment running with `NODE_ENV=production` always uses the production host regardless of this value, so the sandbox host (`api.sandbox.push.apple.com`) is only reachable when `NODE_ENV` is *not* `production`. |

   When the trio is unset, each targeted iOS device returns `status: "failed"`
   with code `MESSAGE_SEND_FAILED` and the message `APNs credentials not
   configured …`.

3. **Register device tokens** — the mobile SDK registers each device's APNs
   token (see [Token onboarding](#token-onboarding)); no per-device key setup is
   needed.

> **Sandbox vs production** tokens are not interchangeable. A token minted by a
> development build only resolves against the sandbox host, while App Store /
> TestFlight tokens only resolve against the production host. APNs routing is
> driven by `NODE_ENV` first: when `NODE_ENV=production` the platform always
> targets the production host, and `DEVOTEL_APNS_PRODUCTION` has no effect.
> `DEVOTEL_APNS_PRODUCTION=true` only forces the production host under a
> non-production `NODE_ENV`. Consequently you **cannot** test a sandbox
> (development-build) token against a production deployment — it will be
> misrouted to the production host and rejected with `BadDeviceToken`. Test
> sandbox tokens against a non-production deployment instead.

## Web Push setup (VAPID)

Web Push uses a single application-server **VAPID keypair** (RFC 8291 /
RFC 8292) that the operator generates once and provides to the platform via
environment.

1. **Generate the keypair** — run once and keep the output:

   ```bash theme={null}
   npx web-push generate-vapid-keys
   ```

   This prints a base64url **public** key and a **private** key.

2. **Configure the environment** — set the three variables on the API / push
   worker:

   | Variable                    | Required       | Notes                                                                                                                                      |
   | --------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
   | `DEVOTEL_VAPID_PUBLIC_KEY`  | yes (web push) | base64url public key; shared with the browser at `pushManager.subscribe` time.                                                             |
   | `DEVOTEL_VAPID_PRIVATE_KEY` | yes (web push) | Signs the VAPID JWT in the `Authorization` header on every delivery. **Secret** — provision via your secret store, never commit it.        |
   | `DEVOTEL_VAPID_SUBJECT`     | yes (web push) | A `mailto:` URI (or `https` URL) push services use to contact the app owner about abuse / outage, e.g. `mailto:push-ops@orbit.devotel.io`. |

   When these are unset the native channels still work, but a web-push
   delivery cannot complete: the send still returns `201`, and that device's
   entry in `notifications[]` comes back with `status: "failed"` and an error
   noting Web Push is not configured. Native (APNs / FCM) targets in the same
   send are unaffected.

3. **Browser registration** — the web SDK calls `OrbitPush.isSupported()` and
   then `pushManager.subscribe` with the **public** key, registering the
   resulting subscription as a device token (see [Token onboarding](#token-onboarding)).

> **Rotating keys** invalidates every existing browser subscription — clients
> must re-subscribe. Rotate only when the private key is compromised.

## Huawei Push setup (HMS)

Huawei devices that ship without Google Play Services cannot receive FCM, so
Orbit delivers to them through **Huawei Push Kit (HMS)**. The platform
authenticates with an OAuth2 `client_credentials` grant against
`oauth-login.cloud.huawei.com` and sends through `push-api.cloud.huawei.com`,
so you configure two credentials from AppGallery Connect via environment.

1. **Create an AppGallery Connect app** — in
   [AppGallery Connect](https://developer.huawei.com/consumer/en/service/josp/agc/index.html),
   create (or open) your project and Android app, then enable **Push Kit**
   under **Grow → Push Kit**. The app's **App ID** and **App secret** are shown
   on the project's **General information** page.

2. **Configure the environment** — set both variables on the API / push worker.
   They must be set **together**; if either is missing the HMS path is treated
   as unconfigured and the Huawei send fails (see details below).

   | Variable                 | Required     | Where to source it                                                                                                                                                              |
   | ------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
   | `DEVOTEL_HMS_APP_ID`     | yes (Huawei) | The app's **App ID** from AppGallery Connect → **General information**. It is the OAuth2 `client_id` and the `<appId>` path segment in the Push Kit send URL.                   |
   | `DEVOTEL_HMS_APP_SECRET` | yes (Huawei) | The app's **App secret** from the same page. Used as the OAuth2 `client_secret` to mint a Push Kit access token. **Secret** — provision via your secret store, never commit it. |

   When both are unset the native and web channels still work, but a Huawei
   send fails with `Huawei Push Kit not configured — DEVOTEL_HMS_APP_ID and
   DEVOTEL_HMS_APP_SECRET are required`. iOS / Android / Web targets in the
   same send are unaffected.

3. **Register device tokens** — register each Huawei device with
   `platform: "huawei"` and the raw HMS Push Kit registration token returned by
   the HMS SDK on the device (see [Token onboarding](#token-onboarding)). Push
   Kit access tokens are cached and refreshed automatically; no manual token
   management is needed.

## Token onboarding

1. Integrate the Devotel mobile SDK (`@devotel/orbit-mobile`) or the web SDK (`@devotel/orbit-web`).
2. The SDK requests notification permission and registers the device token with `POST /api/v1/push/device-tokens` (alias: `POST /api/v1/push/register`) against the user's Orbit identity.
3. Tokens are scoped per (org, app, user) and rotated automatically on platform-token refresh.

## Common errors

| Code                    | HTTP | Cause                                                                                                                                                                                                                                 | Fix                                                                                          |
| ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `VALIDATION_ERROR`      | 422  | Request body or path parameter failed schema validation (e.g. neither `device_token_ids` nor `user_ids` supplied, `platform=web` without a `subscription`/`token`, or the title + body + data payload exceeds the APNs/FCM size cap). | Correct the offending field; see `error.details` for the specific path.                      |
| `NO_DEVICE_TOKENS`      | 422  | No deliverable device tokens matched the targets — none registered, all disabled, or all suppressed by a STOP / opt-out.                                                                                                              | Confirm the SDK has registered a token for the user and that the audience has not opted out. |
| `BROADCAST_TOO_LARGE`   | 422  | A `user_ids: ["*"]` broadcast would target more than the per-tenant recipient cap.                                                                                                                                                    | Send to explicit `user_ids` or split the audience into segments.                             |
| `INVALID_PUSH_ENDPOINT` | 422  | A Web Push `subscription.endpoint` is not a publicly reachable HTTPS URL (it resolves to a private / link-local address).                                                                                                             | Register the genuine endpoint returned by the browser's `pushManager.subscribe`.             |
| `NOT_FOUND`             | 404  | The referenced device token id does not exist in this tenant (delete / update by id).                                                                                                                                                 | Re-fetch the token from `GET /api/v1/push/device-tokens`.                                    |

A per-device delivery failure (expired or unregistered token, a provider
outage, or unconfigured Web Push) is **not** a top-level error: the send
returns `201` and the affected device appears in `notifications[]` with
`status: "failed"` and an `error` string. Tokens the platform reports as
permanently gone are removed automatically, so the next send skips them.

## Pricing

Push delivery itself is free of carrier fees. Orbit charges a per-1M flat platform fee for fan-out. See the [pricing page](https://orbit.devotel.io/pricing).
