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
Code
{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.
Code
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.
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:
Code
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.
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.
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.
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.
Requests
- Bodies are JSON. Send
Content-Type: application/json. - Unknown audience fields in message create and preview are rejected with
400so a misspelled selector cannot broaden a send. Other routes have their own validation rules. - Query parameters named
limitare 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.
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:
Code
The Journeys routes are the one exception. They use a structured envelope so that validation issues can be enumerated:
Code
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-Keyon 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}/messagesand match onnamebefore retrying. - Give every campaign a distinct
nameso 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.
Code
Code
| 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.
Code
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_atgiven as a date string is parsed in the server's local timezone, so the same string means different instants under different containerTZsettings. Pass epoch seconds when the exact instant matters.- There is no
/v1route 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.
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.