Subscriptions and users
A subscription is one addressable channel record — a single device's push token on one
platform. A user is the person those subscriptions hang off, joined by external_id. One
user can own many subscriptions; a subscription with no external_id belongs to its own
anonymous user.
This page covers device registration, session pings, the read routes for users and subscriptions, test subscriptions, and the public web push configuration document.
All examples use https://app.openpush.ai, the OpenPush API base URL.
Two credentials appear on this page and they are not interchangeable:
X-OP-SDK-Key— ingest only. Ships inside your app binary by design. Registers devices, posts receipts and events. It can never satisfy an admin route.X-OP-API-Key— full admin of one app. Never ship it in a binary.
See Apps, keys and settings for key management and Users and subscriptions for the identity model.
POST /v1/apps/{app_id}/subscriptions
Registers a device, or updates the one already registered on that push token. The upsert key is
(app, token), so calling this on every cold start is the intended usage — it is idempotent.
Auth: X-OP-SDK-Key
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
app_id | string | yes | The app this device belongs to |
Body
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
token | string | yes | — | The push token from APNs, FCM, or the browser subscription |
platform | string | no | android | ios, android or web |
external_id | string | null | no | — | Your own user id. See "Absent, present, or empty" below |
external_id_auth_hash | string | conditional | — | Required when identity verification is on for the app |
aliases | object | no | — | {label: id} map of secondary identifiers |
tags | object | no | — | Key/value pairs that segments filter on. Merged per key |
status | integer | no | leave unchanged | Subscription status. Must be a real JSON integer — see Status codes |
app_version | string | no | — | Your app's version string; filterable in segments |
device | string | no | — | Device model |
device_name | string | no | — | Human-readable device name. Never overwritten with a blank |
country | string | no | — | Two-letter country code; filterable in segments |
language | string | no | — | Language code. Drives per-language content selection on sends |
timezone | string | no | — | IANA zone name (Asia/Kolkata) or a ±HH:MM offset |
sandbox | boolean | no | unset | iOS only. Routes this one device to the APNs sandbox host. Omit it and the app-level setting decides |
ad_id | string | no | — | Advertising identifier. Stored only if the app's ad-id collection switch is on |
lat | number | no | — | Latitude. Stored only if the location switch is on, and only together with lng |
lng | number | no | — | Longitude. Same gate as lat |
email | string | no | — | Stored only if the email switch is on. Truncated to 254 characters |
Numeric strings are accepted for lat and lng, because JSON crossing a Unity or Flutter
bridge routinely arrives quoted. Values that are not finite numbers are dropped rather than
rejected.
Absent, present, or empty
Three body keys distinguish "not sent" from "sent as empty", and the distinction is load-bearing:
external_idabsent — no identity claim. The device stays attached to whatever user it already belonged to.external_idpresent and empty (""ornull) — an explicit logout. A device attached to an identified person is moved to a fresh anonymous user, so the next anonymous session does not inherit the previous person's tags, aliases and history. A device that was already anonymous stays on its existing record.statusabsent — the stored status and its detail string are left exactly as they were. This is what lets an old binary that never sendsstatusre-register without silently reversing an opt-out.
Tags and aliases follow the same convention: a key sent with an empty string or null deletes
that key. Keys you do not send are left alone.
Identity verification
When the app's identity_verification setting is on, any registration that claims
external_id, tags or aliases must also carry:
Code
Compute it on your server — the REST key must never reach the binary. The hash is checked against every active REST key on the app, so adding a second backend key does not invalidate hashes minted with the first.
Code
A failed check downgrades, it does not reject. The identity fields are stripped, the device
still registers (anonymously if it was not already attached), push delivery is unaffected, and
the 200 response carries an identity_rejected note explaining what was dropped. Rejecting
with a 400 would brick registration for every user still on an app version that predates the
hash, which turns a security toggle into an outage.
A registration that claims nothing at all is unaffected by the setting.
Aliases
aliases is a {label: id} map — Steam ids, analytics ids, whatever else resolves to the same
person. Aliases are lookup and targeting only: they are never used to merge two users, and
an alias that collides with one another user already holds is written onto the calling user
only.
Refusals come back as an aliases_refused array on the 200, not as an error:
external_idis a reserved label and is always refused, at any value including an empty one — it would be an unverified second spelling of the field identity verification exists to check.- A user holds at most 10 alias pairs. Labels already stored always survive, so re-sending a full map at the cap refuses nothing; genuinely new labels beyond the cap are refused in sorted order.
A body where aliases is not an object is a caller bug and does return 400.
Data collection switches
ad_id, location (lat/lng) and email are each gated by a per-app switch, all off by
default. A value for a category that is off is dropped, not rejected — a 400 would break
every device on a released binary the moment an admin flips a switch. The dropped category names
come back in not_collected.
A location is only stored when both coordinates arrive; half a fix is not a fix.
IP storage is a server-wide setting (full, truncated, or off) rather than a per-app one,
and is reported on the app settings response.
Side effects
- A session is counted if the user's last session was more than the server's session gap (30 minutes by default) ago.
- The user's local-hour activity histogram is bumped, which is what trains Best-hour delivery.
- The journeys session hook fires, so a journey with a session trigger can enter this user.
Example request
Code
Example response
Code
| Field | Type | Description |
|---|---|---|
id | string | The subscription id |
app | string | The app id |
status | integer | The status now stored on the row — not an echo of what you sent |
status_name | string | Short label for that status. Every positive value reads Subscribed |
created | boolean | true on the first registration of this token, false on an update |
not_collected | array | Present only when a value was dropped by a collection switch |
aliases_refused | array | Present only when an alias label was reserved or over the cap |
identity_rejected | string | Present only when an identity claim failed verification |
A response carrying notes still means the device registered:
Code
Errors
| Status | Message | Cause |
|---|---|---|
400 | token required | No token in the body |
400 | status must be an integer status code, got … | status was a float, a boolean, or a quoted string |
400 | status <n> (<name>) is written by … | A status only the provider, the console or the REST API may write |
400 | status -50 is not a status code… | Provisional authorization is bit 64 of a positive value, not a negative code |
400 | status <n> is above 511… | Above the sum of the nine iOS authorization bits |
400 | aliases must be an object of label → id… | aliases was not an object |
401 | bad X-OP-SDK-Key | Missing key, wrong key, or a REST key on an ingest route |
Status codes
status is a signed integer. A subscription is targetable when its status is positive and the
address has not been retired — that is the whole rule; there is no separate "subscribed" flag.
The specific integers were chosen to match the vocabulary common push-platform subscriber exports
already use, so a notification_types column arrives from a CSV import and lands in status
without translation. Positive values are Apple's UNAuthorizationOptions bitmask, carried through
as iOS reports it; the negative values are the sentinel set those exports carry. OpenPush's own
meaning for each value is the table below, not the source system's.
| Value | Name | May a device send it? |
|---|---|---|
1–511 | Subscribed | Yes — this is the iOS authorization bitmask |
0 | Never Subscribed | Yes — permission was not granted |
-2 | Unsubscribed | Yes — the person opted out |
-18 | Never Prompted | Yes — permission has never been requested |
-19 | Never Answered | Yes — the prompt was shown and dismissed |
-10 | Uninstalled | No — written by the provider when a send finds a dead token |
-22 | Dashboard Disabled | No — written from the console |
-31 | Disabled via REST API | No — written by the REST API or a CSV import |
-99 | Never Subscribed | No — a legacy value that only appears in imported data |
Sending one of the three refused negatives returns a 400 naming which system owns that value.
The iOS authorization bitmask
On iOS, a positive status is the OR of the authorization bits the OS reported — Apple's
UNAuthorizationOptions, stored verbatim. Every positive combination is subscribed and
targetable.
| Bit | Meaning |
|---|---|
1 | Badge |
2 | Sound |
4 | Alert |
8 | CarPlay |
16 | Critical |
32 | App Settings |
64 | Provisional |
128 | Announcement |
256 | Time Sensitive |
The maximum is 511, the sum of all nine bits. Anything above it is not an authorization value and is refused.
Provisional authorization has no status of its own: send it as a positive value with bit 64 set
(for example 80 = Provisional + App Settings + Alert… whatever iOS actually reported), the
same way iOS reports it. -50 is not a status code.
Alongside status, OpenPush stores a status_detail string derived from the value: the named
reason for a non-positive status, or the list of authorization bits for a positive one.
POST /v1/apps/{app_id}/sessions
Records an app-foreground observation without inventing a delivery receipt. Receipts belong to a message; an app session has none.
Auth: X-OP-SDK-Key
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | yes | The push token of a device already registered on this app |
Example request
Code
Example response
Code
counted is true only when more than the server's session gap (30 minutes by default) has
passed since this user's previous session. SDKs may call it as often as they like; the
server-side gap is the authoritative definition of a session. A counted session updates the
user's session count and last-session time and feeds the activity histogram. Every call updates
the subscription's last_seen regardless.
Errors
| Status | Message | Cause |
|---|---|---|
400 | token required | Empty or missing token |
401 | bad X-OP-SDK-Key | Bad or missing SDK key |
404 | unknown subscription | No device registered on that token for this app |
404 | unknown app '<id>' | No such app |
GET /v1/apps/{app_id}/users
Lists user records — one per person, with their tags, aliases and subscription count.
Auth: X-OP-API-Key
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
search | string | — | Substring match over external_id and the raw tags JSON |
limit | integer | 100 | Page size. Clamped to a ceiling of 1000 |
order | string | — | Pass id to switch to the keyset walk. See Pagination |
cursor | string | — | Opaque cursor from a previous page's next_cursor |
Example request
Code
Example response
Code
total is the length of this page, not of the table. Use has_more and next_cursor to
walk.
GET /v1/apps/{app_id}/subscriptions
Lists subscription records — one per device token.
Auth: X-OP-API-Key
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
search | string | — | Substring match over token, external_id and id |
test | integer | 0 | 1 restricts the list to test subscriptions |
limit | integer | 200 | Page size. Clamped to a ceiling of 1000 |
order | string | — | Pass id to switch to the keyset walk |
cursor | string | — | Opaque cursor from a previous page's next_cursor |
Example request
Code
Example response
Code
Tokens are truncated to their first 20 characters plus an ellipsis on every list route. A list route is for looking at, and nobody can migrate a device with a fifth of its push token. The whole token is only in the admin-only NDJSON export — see Import and export.
Two more fields are worth naming:
retired_at— this address generation was superseded or unlinked. A retired subscription is not targetable even if itsstatusis still positive, because the status records what the provider last said and is not destroyed by retirement.unsubscribed_at— when the device stopped being subscribed, for whatever reason.nullfor rows that left before the column existed; it is not backfilled, because a fabricated date puts a spike of departures on one arbitrary day.
Provider-routing bookkeeping used by the OneSignal migration bridge is stripped from this view.
Pagination
Both read routes above share one pagination model, with two modes chosen explicitly.
Default — no cursor, no order. Newest-first by last session (users) or last seen
(subscriptions), limit rows, no cursor:
Code
Newest-first ordering is neither unique nor stable, so a cursor over it would be a lie. The mode says so out loud rather than returning a null cursor that looks like end-of-data.
Keyset walk — pass ?order=id, or any ?cursor=. Ascending on the primary key, with a
base64url cursor carrying a version tag:
Code
Feed next_cursor back as ?cursor= for the next page. A short page means the walk is
finished — you do not need a final empty request. A malformed cursor is a 400.
Offset pagination is deliberately not offered. OFFSET n makes the database produce and discard
n rows, and it is wrong on a table that changes while you walk it: a row deleted behind the
cursor shifts every later page up and silently skips a row.
Errors
| Status | Message | Cause |
|---|---|---|
400 | cursor decode message | Malformed or wrong-version cursor |
401 | bad X-OP-API-Key | Bad or missing REST key |
Test subscriptions
Test devices bypass the frequency cap and quiet hours on every send — including ordinary campaigns they merely happen to be in the audience of. That is a device-level guard bypass, not a label; keep the list short and made of devices you own.
GET /v1/apps/{app_id}/test-subscriptions
Auth: X-OP-API-Key
Code
Code
Tokens are truncated here too.
POST /v1/apps/{app_id}/test-subscriptions
Marks a device as a test device. Repeated calls rename rather than duplicate.
Auth: X-OP-API-Key
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
subscription_id | string | one of | — | An existing subscription id |
token | string | one of | — | A push token. If it is not registered yet, it is registered now |
name | string | no | auto | Label shown in the console and in the list response |
platform | string | no | android | Used only when registering a brand-new token |
external_id | string | no | — | Used only when registering a brand-new token |
Code
Code
Side effect: anything quiet hours was currently holding for that device is released immediately, so the device is not left parked in a window it is documented to ignore. Marking a device also keeps the managed "Testing Devices" segment in step.
DELETE /v1/apps/{app_id}/test-subscriptions/{sub_id}
Auth: X-OP-API-Key
Code
Code
The subscription itself is untouched — only its test flag is removed.
Errors
| Status | Message | Cause |
|---|---|---|
400 | token or subscription_id required | Neither supplied, or the id matched nothing |
401 | bad X-OP-API-Key | Bad or missing REST key |
404 | unknown app '<id>' | No such app |
404 | not a test subscription | On DELETE, that subscription is not marked as a test device |
GET /v1/apps/{app_id}/webpush-config
Everything a browser needs to subscribe to web push for this app.
Auth: none — deliberately. Every field in the response is something a web page publishes to its visitors anyway, and the response is assembled field by field so nothing else can join the list by accident. The service-account JSON and the VAPID private key are stored encrypted and are never read by this route.
Example request
Code
Example response
Code
| Field | Type | Description |
|---|---|---|
app | string | The app id |
vapid_public | string | The public half of the VAPID pair — the browser's applicationServerKey |
site_url | string | The site URL configured for this app |
firebase | object | The firebaseConfig values, re-projected through an allowlist: apiKey, authDomain, projectId, storageBucket, messagingSenderId, appId, measurementId, databaseURL. Empty values are omitted |
sdk_key | string | The app's ingest-only SDK key |
needs_link_code | boolean | Whether this app registers devices through a link code |
sdk_key being in an unauthenticated response is not a leak: an SDK key ships inside public
binaries by design. It can register a device and post a receipt; it cannot read an audience,
send a push, or rotate anything.
Errors
| Status | Message | Cause |
|---|---|---|
404 | unknown app '<id>' | No such app |
404 | '<app>' has no web push credential — add one under Settings → Platforms → Web | The app exists but has no web credential configured |
"Not configured" and "configured with nothing in it" are different answers, and this route says which. See Web push for what the web story does and does not include.
Limits and notes
- No rate limit applies to registration or session pings. What bounds these routes is the 8 MB default request body limit.
limiton both read routes is clamped to 1000, whatever you ask for.- A user holds at most 10 aliases. There is no cap on the number of subscriptions per user.
- Tokens are truncated on every list route. Full tokens exist only in the NDJSON export.
- There is no route to delete a user or a subscription. Devices leave through opt-out
(
status: -2), a provider verdict, or address retirement. - There is no per-user preference centre and no per-message opt-out class. Quiet hours and the frequency cap are single app-level settings.
- Data-collection switches are off by default, and turning one off also erases the data already stored for that category — the settings response reports how many rows were purged.
- Registration auto-provisions an unknown app only when the request carries the platform-level SDK key, which is held by the OpenPush team. A per-app key cannot create an app.
Related
- API overview — auth model, error convention, body-size limits
- Apps, keys and settings — collection switches, identity verification, quiet hours
- Segments — filtering on tags, country, language, sessions
- Events and ingest — custom events and delivery receipts
- Users and subscriptions — the identity model in prose
- Web push — what web push covers today