OpenPush
OpenPush sends push notifications to iOS, Android and web devices through FCM using your own credentials, and to iOS devices directly over APNs when the app has an APNs key and the device registered a real APNs device token. Delivery uses the app's configured platform credentials.
Base URL
The API is served from https://app.openpush.ai, and all examples use that
host.
Everything except the health routes lives under /v1, and almost every route
is scoped to a single app by its slug in the path:
Code
{app_id} is the app's slug — the lowercase id you chose when the app was
created (runner-club), not a generated identifier. Ids the API returns are
prefixed and twelve hex characters: msg_9f21c4a70b3d, sub_4b7e0a1c93df.
Authentication
Every authenticated public REST API route takes a key in a header. Browser OAuth
is available separately for MCP and the scanner CLI; those routes are outside
/v1 and omitted from this REST contract. There is no body signature.
| 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, delivery receipts and custom events, register Live Activity tokens |
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, and it cannot send a message or read your audience. A REST key must never be shipped in a binary: it is full admin of that app, and a leaked one is a full compromise. Key comparison is constant-time.
A per-app REST key resolves to exactly one app, so it cannot cross an app or a
workspace boundary. The deployment-level OP_API_KEY / OP_SDK_KEY are the
exception: they match every app, and OP_API_KEY is the only credential
accepted by GET /v1/apps and POST /v1/apps. Set OP_GLOBAL_KEYS=off to
close that hatch on /v1.
The two Live Activity migration routes additionally accept
Authorization: Key <REST API key>. That spelling works only there — everywhere
else on /v1 the native X-OP-API-Key header is required.
Errors
Successful responses have no envelope: the resource is the body. An ordinary error carries a single human-readable string:
Code
There are two exceptions. The Journeys routes use a structured envelope so
validation issues can be enumerated
({"errors": [{"code": "invalid-payload", "title": "…", "meta": {…}}]}), and
the Live Activity migration routes return a plain list
of strings ({"errors": ["use exactly one targeting method"]}).
An unhandled exception returns 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 it when reporting a problem.
404 on a send is worth calling out: an immediate send whose targeting matches
no sendable subscription is refused, not 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: 60 |
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 8 MB
request body ceiling (OP_MAX_BODY_BYTES; 150 MB on import uploads via
OP_MAX_IMPORT_BYTES) and the limit clamp of 1000 on paginated reads.
A 429 that originates inside APNs or FCM is the provider throttling
OpenPush, not OpenPush throttling you; it drives our own retry and backoff and
never surfaces as an API response.
Idempotency
One route on /v1 reads an Idempotency-Key header: POST /v1/apps/{app_id}/messages. The value is 16-128 characters and yours to
choose. For 24 hours, the same key with the same body replays the original
response — same status, same message id, and no second push — with
Idempotent-Replayed: true on it. The same key with a different body is a
409, and so is a key whose first request is still in flight. Nothing else on
/v1 reads the header, and a create sent without it is not deduplicated at
all: a retry is a second send, to the same devices.
The Live Activity migration start route has its own, older spelling:
a body field named idempotency_key whose value must be a UUID. A replay
there returns the original notification_id with the same
Idempotent-Replayed: true header and sends nothing; those records are
retained for 30 days. It is not honoured on the update/end route.
Practical guidance: send an Idempotency-Key on every create you would retry.
Without one, treat a send request that times out as possibly delivered — give
every campaign a distinct name, then poll GET /v1/apps/{app_id}/messages
and match on name before retrying.
GET /healthz returns a capability snapshot (provider readiness, queue depth, row counts); /status.json and /status render a wider operational picture with every probe wrapped independently.send-test to registered test devices only. Create is not idempotent: a retried request is a second send./v1/ingest route, plus the two admin reads over what arrived — recent events and the event catalogue. Custom events are the only ingest path with a rate limit (500 per app per 5 seconds).{"errors": [...]} envelope./v1 route sends a Live Activity.