# API overview


The OpenPush REST API is a JSON-over-HTTPS interface to the OpenPush platform. Every
resource except the health endpoints lives under `/v1`, and almost every route is
scoped to a single app by its id in the path.

The base URL is `https://app.openpush.ai`, and every example on this page uses it.

## Path shape

```
https://app.openpush.ai/v1/apps/{app_id}/{resource}
```

`{app_id}` is the app's **slug** — the lowercase id you chose when the app was created
(`runner-club`, `acme-fitness`). It is not a generated identifier. Everything else the API
returns is a prefixed id with twelve hex characters: `msg_9f21c4a70b3d`,
`sub_4b7e0a1c93df`, `tpl_c0d81a45e296`, `seg_51ba7fd0c48e`.

There is one versioned path segment, `/v1`, and it has never changed. Endpoints are added
under it rather than behind a new version.

## Authentication

Every authenticated route takes a key in a header. There is no OAuth flow, no session
token, and no signature scheme on the request body.

| Header | Key kind | What it can do |
|---|---|---|
| `X-OP-API-Key` | REST API key | Full admin of **one** app — send messages, read the audience, manage segments, templates, dynamic content, journeys, settings, exports, and key rotation |
| `X-OP-SDK-Key` | SDK key | Ingest only — register a subscription, post sessions, post delivery receipts, post custom events, register Live Activity tokens |

A third kind, `legacy`, exists for migrations. It is presented in `X-OP-API-Key` and is
treated exactly like a REST key.

The two kinds are enforced by kind, not by convention: an SDK key presented on an admin
route fails, and a REST key presented on an ingest route fails. That is what makes the SDK
key safe to ship inside a public app binary — it is designed to be extracted.

```bash
# Admin route
curl https://app.openpush.ai/v1/apps/runner-club/settings \
  -H "X-OP-API-Key: $OP_REST_KEY"

# Ingest route
curl -X POST https://app.openpush.ai/v1/apps/runner-club/sessions \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"token":"fRT9…"}'
```

Key comparison is constant-time, so neither the length of a key nor a matching prefix
leaks through response timing.

### Scoping

A per-app key is looked up as the pair `(app in the path, key in the header)`. A REST key
issued for app A therefore cannot read or write app B, and because every app belongs to
exactly one workspace, it cannot cross a workspace boundary either.

Two routes are the exception — `GET /v1/apps` and `POST /v1/apps` — because listing or
creating apps is not an operation any single app's key can express. See
[Apps, keys and settings](01-apps-keys-settings.md).

### The platform-level keys

OpenPush also carries two environment-level keys that match on **every** app. They are
held by the OpenPush team, are never issued to a customer, and are documented here only
because they explain which routes your own key cannot reach:

| Environment variable | Acts as | Extra power |
|---|---|---|
| `OP_API_KEY` | REST key for every app | The only credential accepted by `GET /v1/apps` and `POST /v1/apps` |
| `OP_SDK_KEY` | SDK key for every app | Registering a subscription against an unknown app id **auto-creates that app** |

They cross workspace boundaries by design, which is precisely why they are not a
customer credential. The platform can additionally be run with `OP_GLOBAL_KEYS=off`,
after which neither authenticates anything on `/v1` at all. Either way, the practical
consequence for an integrator is the same: **`GET /v1/apps` and `POST /v1/apps` are not
part of the customer API** — list and create apps in the console.

One caveat, stated plainly: `OP_GLOBAL_KEYS=off` does **not** stop `OP_API_KEY` from
working as the console break-glass password. It closes an API surface, not the dashboard
door.

OpenPush refuses to boot if either value is still a development default (`dev` and
`sdk-dev`), outside dry-run mode.

### The compatibility header

Live Activity migration routes also accept this header spelling:

```
Authorization: Key <REST API key>
```

`Basic <key>`, `Bearer <key>`, and a bare key value are accepted in the same header. This
spelling works **only** on those compatibility routes — everywhere else on `/v1` the
native `X-OP-API-Key` header is required. See
[Live Activities](08-live-activities.md).

### Rotation and disabling

`POST /v1/apps/{app_id}/keys/{kind}/rotate` returns a new secret once. The old REST
secret remains valid for 24 hours; the old SDK secret remains valid for seven days.
The response includes its expiry. See [key management](01-apps-keys-settings.md#keys).

An app can hold more than one active REST key. Create, disable, delete, or rotate one
through its app-scoped key routes. Full details: [Apps, keys and settings](01-apps-keys-settings.md).

### Identity verification

An app can require that a subscription proving an `external_id` also present
`HMAC-SHA256(external_id)` keyed with one of the app's active REST keys, computed on your
server. The setting is off by default. When it is on, a registration that fails the check
is **downgraded, not rejected** — the identity fields are stripped, the device still
registers anonymously, and push delivery is unaffected. See
[Subscriptions and users](03-subscriptions-users.md).

## Requests

- Bodies are JSON. Send `Content-Type: application/json`.
- Unknown audience fields in message create and preview are rejected with `400` so a
  misspelled selector cannot broaden a send. Other routes have their own validation rules.
- Query parameters named `limit` are clamped to a maximum of **1000** on the paginated
  read routes (users, subscriptions, journeys).

### Body size

A single security middleware applies to every route:

| Limit | Setting | Value |
|---|---|---|
| Maximum request body | `OP_MAX_BODY_BYTES` | **8 MB** |
| Maximum body on import upload routes | `OP_MAX_IMPORT_BYTES` | **150 MB** |

Both are platform settings, held by the OpenPush team; treat the values above as the
ones you will be served.

Separately, the rendered per-device push payload is capped at **4 KB** by FCM's data
envelope. That cap is enforced at send time, not at request time — see
[Messages](02-messages.md).

## Responses and errors

Successful responses are JSON objects. There is no envelope: the resource is the body.

An ordinary error carries a single human-readable string:

```json
{"detail": "priority must be 'high' or 'normal', not 'urgent'"}
```

The Journeys routes are the one exception. They use a structured envelope so that
validation issues can be enumerated:

```json
{"errors": [{"code": "invalid-payload", "title": "…", "meta": {"issues": [...]}}]}
```

If an unhandled exception ever occurs, `/v1` and `/healthz` return a JSON body containing
a short random error id, and nothing else — no stack trace, no module names, no SQL. The
traceback is logged on the server against that id. Quote the id when you report a problem.

### Status codes

| Code | Meaning on this API |
|---|---|
| `200` | Success. Message create returns `200` even for a scheduled send |
| `201` | Journey created |
| `400` | Malformed body, invalid field value, invalid segment filter or target |
| `401` | Missing or wrong key for the header the route requires |
| `403` | Route refused for this app (for example, the shared sample app has no REST send path) |
| `404` | Unknown app, message, template, segment, subscription — or an immediate send whose audience resolved to zero devices |
| `409` | Conflicting state: cancelling a message that is not scheduled, promoting a winner twice, a stale journey concurrency key |
| `413` | Dynamic content table or app quota exceeded |
| `429` | Rate limited — only on custom events and the Live Activity compatibility routes |

`404` on a send is worth calling out: an immediate send whose targeting matches no
sendable subscription is refused rather than recorded as a zero-audience campaign.

## Rate limits

These routes have dedicated rate limits. Event accounting is shared across server
replicas; Live Activity migration rate accounting is process local.

| Route | Limit | Response |
|---|---|---|
| `POST /v1/apps/{app_id}/events` | 500 events per app per 5 seconds | `429` with `Retry-After: 5` |
| Live Activity migration routes | 60 requests per app per 60 seconds (`OP_COMPAT_RATE_MAX` / `OP_COMPAT_RATE_WINDOW_S`) | `429` with `Retry-After` |

**Nothing else is rate limited.** Not message create, not `/v1/ingest`, not subscription
registration, not sessions, not send-test, not any segment, template, dynamic-content or
list route. What bounds those instead is the body size ceiling and the `limit=1000` page
clamp above.

`429` responses that originate inside APNs or FCM are the *providers* throttling
OpenPush, not OpenPush throttling you. They drive OpenPush's own retry and backoff and
never surface as an API response.

## Idempotency

`POST /v1/apps/{app_id}/messages` accepts a 16–128 character `Idempotency-Key`
header. For 24 hours, the same key and body replay the original response without a
second send; the same key with different content returns `409`.

The Live Activity migration **start** route has a separate body field named
`idempotency_key`. It requires a UUID and retains replay records for 30 days.
The header above applies only to message creation.

Practical guidance:

- Send an `Idempotency-Key` on every message create that may be retried.
- If a request omitted that header, treat a timeout as *possibly delivered*. Poll
  `GET /v1/apps/{app_id}/messages` and match on `name` before retrying.
- Give every campaign a distinct `name` so a duplicate is visible in the message list.
- For a scheduled send, prefer creating it once and reading it back over blind retries —
  a scheduled create is cheap to verify and cheap to cancel.

## Image URLs

`image_url` is validated identically wherever it appears — on message create, on
send-test, and on template create and update.

| Rule | Failure |
|---|---|
| Empty or absent | Accepted; means no image |
| Must be a string | `400 "Image URLs must be text"` |
| Maximum 2048 characters | `400 "Image URLs are capped at 2048 characters"` |
| No whitespace anywhere in the value | `400 "Image URLs must be https:// with no spaces"` |
| Must be `https://` with a host, or a media path on this server matching `/media/<app>/med_<id>.(png\|jpg\|gif)` | `400 "Image URLs must be https:// — devices refuse anything else"` |

`http://` is refused because devices refuse it. There is no exception for localhost or a
private network.

Uploading an image to OpenPush is a **console action** — there is no `/v1` media upload
route. Uploads are processed into two kinds: `image` (2000 px maximum edge, 300 px
minimum width, roughly a 1 MB budget) and `icon` (512 px maximum edge, 64 px minimum
width, roughly 200 KB). Accepted inputs are JPEG, PNG, GIF and WEBP, with a hard ceiling
of 40 megapixels. If media storage is not enabled on the platform, uploads are declined
and you paste an HTTPS URL instead — which is fully supported by the API, and is the path
to build against if you want one that always works.

## Health and status

These three routes are unauthenticated by design, so an uptime checker does not need a
credential.

### `GET /healthz`

Liveness plus a capability snapshot.

```bash
curl https://app.openpush.ai/healthz
```

```json
{
  "ok": true,
  "version": "0.1.0",
  "apps": 4,
  "subs": 128413,
  "commit": "9f21c4a70b3d",
  "dry_run": false,
  "fcm": true,
  "apns": true,
  "apns_source": "app",
  "queue": {"backend": "redis", "ready": 0, "leased": 8, "delayed": 12, "dead": 0},
  "early_access_configured": false,
  "keys_defaulted": false
}
```

| Field | Type | Meaning |
|---|---|---|
| `ok` | bool | Always `true` when the route answers at all |
| `version` | string | Server version string |
| `apps` | int | Apps on this server |
| `subs` | int | Subscription rows on this server |
| `commit` | string | First 12 characters of the deployed git sha, or `"unknown"` |
| `dry_run` | bool | Whether the server is running without real provider delivery |
| `fcm` | bool | Server-wide FCM readiness |
| `apns` | bool | Platform-wide APNs readiness — the `.p8` is parsed and a provider token actually signed |
| `apns_source` | string | Where the APNs credential came from |
| `queue` | object | Send-queue backend name plus `ready`, `leased`, `delayed` and `dead` depths. Degrades to `{"backend":"unavailable","error":"…"}` rather than failing the route when the queue backend is down |
| `early_access_configured` | bool | Whether early-access signup is wired up |
| `keys_defaulted` | bool | `true` if the platform-level keys are still development defaults |

`fcm` and `apns` here are the **platform-wide** answer, not yours. The number you almost
always want is per-app readiness, on `GET /v1/apps/{app_id}/settings → platforms`.

A growing `queue.ready` with a flat `queue.dead` means workers are behind; a growing
`dead` means devices are being given up on.

**Errors:** none. The route is designed to answer during an outage.

### `GET /status.json`

A wider, still non-secret operational snapshot: version, deployed commit, uptime, server
time, database dialect and reachability, per-table row counts, migration steps applied,
provider readiness, queue stats, daily-history freshness, and a `subsystems` map that
reports whether optional subsystems are *configured* — never their secrets.

```bash
curl https://app.openpush.ai/status.json
```

Every probe inside it is wrapped independently, so one failing subsystem degrades to an
error string in its own key and sets `ok` to `false` rather than failing the whole
response.

**Errors:** none.

### `GET /status`

The same snapshot rendered as an HTML status page. Intended for humans and for linking in
an incident channel.

**Errors:** none.

## Limits

- **No idempotency on `/v1`.** A retried send is a second send.
- **No rate limiting on sends or ingest.** Pace bulk work yourself.
- Rate limits and their windows are **per process**, not per cluster.
- `schedule_at` given as a date string is parsed in the **server's local timezone**, so
  the same string means different instants under different container `TZ` settings. Pass
  epoch seconds when the exact instant matters.
- There is no `/v1` route for uploading media or listing apps for one workspace.
  Keys can be disabled through their individual app-scoped route.
- Workspace roles (`viewer`, `manager`, `admin`) apply to console sessions only. A REST
  key is not role-scoped — it is full admin of its one app.

## FAQ

**Which key do I put in my mobile app?**
The SDK key, and only the SDK key. It is ingest-only by design and cannot send a message
or read your audience. A REST key in a shipped binary is a full compromise of that app.

**Can one REST key manage several apps?**
No. A per-app REST key resolves to exactly one app. The only credential that spans apps is
the platform-level `OP_API_KEY`, which is held by the OpenPush team and is not issued to
customers. Use one REST key per app.

**How do I avoid double-sending when a request times out?**
Send an `Idempotency-Key` header with message creation and reuse it with the same body
on retry. See [Idempotency](#idempotency).

**Is there a sandbox or test environment?**
Not as a separate host. Use a separate app id for staging, and use
`POST /v1/apps/{app_id}/send-test` to reach only registered test devices without creating
a Sent Messages row.

**Why did my send return 404?**
An immediate send whose targeting resolves to no sendable subscription returns
`404 "no matching subscriptions — did the app register?"`. Check that devices have
registered and that your segment filters are not mutually exclusive.

## Related

- [Apps, keys and settings](01-apps-keys-settings.md)
- [Messages](02-messages.md)
- [Subscriptions and users](03-subscriptions-users.md)
- [Security and limits](../guides/security-and-limits.md)
- [Platform overview](../guides/overview.md)
