# 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](01-apps-keys-settings.md) for key management and
[Users and subscriptions](../guides/users-and-subscriptions.md) 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](#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_id` absent** — no identity claim. The device stays attached to whatever user it
  already belonged to.
- **`external_id` present and empty (`""` or `null`)** — 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.
- **`status` absent** — the stored status and its detail string are left exactly as they were.
  This is what lets an old binary that never sends `status` re-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:

```
external_id_auth_hash = HMAC-SHA256(external_id, key = an active REST API key)
```

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.

```bash
printf '%s' 'player-77' \
  | openssl dgst -sha256 -hmac 'op_rest_9f2c…' -r \
  | cut -d' ' -f1
```

**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_id` is 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](../guides/best-hour-delivery.md).
- The journeys session hook fires, so a journey with a session trigger can enter this user.

### Example request

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2a/subscriptions \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "token": "fN1x8sQ7Tk2:APA91bH…",
        "platform": "android",
        "external_id": "player-77",
        "external_id_auth_hash": "3d0b1c9a…",
        "status": 1,
        "language": "en",
        "country": "IN",
        "timezone": "Asia/Kolkata",
        "app_version": "1.97",
        "device": "Pixel 8",
        "tags": {"tier": "gold", "trial": ""},
        "aliases": {"steam_id": "76561198000000000"}
      }'
```

### Example response

```json
{
  "id": "sub_01hq7m4c2p",
  "app": "app_3f9c2a",
  "status": 1,
  "status_name": "Subscribed",
  "created": true
}
```

| 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:

```json
{
  "id": "sub_01hq7m4c2p",
  "app": "app_3f9c2a",
  "status": 1,
  "status_name": "Subscribed",
  "created": false,
  "not_collected": ["email", "location"],
  "aliases_refused": ["external_id"]
}
```

### 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

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2a/sessions \
  -H "X-OP-SDK-Key: $OP_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"token": "fN1x8sQ7Tk2:APA91bH…"}'
```

### Example response

```json
{"counted": true}
```

`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](#pagination) |
| `cursor` | string | — | Opaque cursor from a previous page's `next_cursor` |

### Example request

```bash
curl "https://app.openpush.ai/v1/apps/app_3f9c2a/users?order=id&limit=2" \
  -H "X-OP-API-Key: $OP_API_KEY"
```

### Example response

```json
{
  "users": [
    {
      "id": "usr_01hq7m3x8a",
      "app": "app_3f9c2a",
      "external_id": "player-77",
      "tags": {"tier": "gold"},
      "aliases": {"steam_id": "76561198000000000"},
      "country": "IN",
      "language": "en",
      "timezone": "Asia/Kolkata",
      "email": null,
      "first_session": 1735689600.0,
      "last_session": 1738368000.0,
      "session_count": 41,
      "subscriptions": 2
    }
  ],
  "total": 1,
  "next_cursor": "djE6dXNyXzAxaHE3bTN4OGE",
  "has_more": true,
  "paging": "keyset on the primary key, ascending"
}
```

`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

```bash
curl "https://app.openpush.ai/v1/apps/app_3f9c2a/subscriptions?order=id&limit=1" \
  -H "X-OP-API-Key: $OP_API_KEY"
```

### Example response

```json
{
  "subscriptions": [
    {
      "id": "sub_01hq7m4c2p",
      "app": "app_3f9c2a",
      "user_id": "usr_01hq7m3x8a",
      "external_id": "player-77",
      "token": "fN1x8sQ7Tk2:APA91bH…",
      "platform": "android",
      "status": 1,
      "status_name": "Subscribed",
      "status_detail": "-",
      "device": "Pixel 8",
      "device_name": "Riya's Pixel",
      "app_version": "1.97",
      "sandbox": null,
      "ip": "203.0.113.0",
      "created_at": 1735689600.0,
      "last_seen": 1738368000.0,
      "unsubscribed_at": null,
      "retired_at": null
    }
  ],
  "total": 1,
  "next_cursor": "djE6c3ViXzAxaHE3bTRjMnA",
  "has_more": true,
  "paging": "keyset on the primary key, ascending"
}
```

**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](09-import-export.md).

Two more fields are worth naming:

- `retired_at` — this address generation was superseded or unlinked. A retired subscription is
  not targetable even if its `status` is 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. `null` for
  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:

```json
{"next_cursor": null, "has_more": null,
 "paging": "add ?order=id for a stable keyset walk with a cursor"}
```

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:

```json
{"next_cursor": "djE6c3ViXzAxaHE3bTRjMnA", "has_more": true,
 "paging": "keyset on the primary key, ascending"}
```

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`

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c2a/test-subscriptions \
  -H "X-OP-API-Key: $OP_API_KEY"
```

```json
{
  "test_subscriptions": [
    {
      "id": "sub_01hq7m4c2p",
      "name": "Riya's Pixel",
      "app": "app_3f9c2a",
      "token": "fN1x8sQ7Tk2:APA91bH…",
      "platform": "android",
      "status": 1,
      "status_name": "Subscribed",
      "created_at": 1735689600.0
    }
  ]
}
```

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 |

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c2a/test-subscriptions \
  -H "X-OP-API-Key: $OP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subscription_id": "sub_01hq7m4c2p", "name": "Riya'"'"'s Pixel"}'
```

```json
{
  "app": "app_3f9c2a",
  "name": "Riya's Pixel",
  "subscription_id": "sub_01hq7m4c2p",
  "note": "test devices ignore the frequency cap and quiet hours on every send"
}
```

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`

```bash
curl -X DELETE \
  https://app.openpush.ai/v1/apps/app_3f9c2a/test-subscriptions/sub_01hq7m4c2p \
  -H "X-OP-API-Key: $OP_API_KEY"
```

```json
{"deleted": true}
```

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

```bash
curl https://app.openpush.ai/v1/apps/app_3f9c2a/webpush-config
```

### Example response

```json
{
  "app": "app_3f9c2a",
  "vapid_public": "BJ8kQ2…",
  "site_url": "https://example.com",
  "firebase": {
    "apiKey": "AIza…",
    "projectId": "example-app",
    "messagingSenderId": "1234567890",
    "appId": "1:1234567890:web:abcd"
  },
  "sdk_key": "<redacted-sdk-key>",
  "needs_link_code": false
}
```

| 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](../guides/web-push.md) 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.
- `limit` on 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](00-overview.md) — auth model, error convention, body-size limits
- [Apps, keys and settings](01-apps-keys-settings.md) — collection switches, identity
  verification, quiet hours
- [Segments](04-segments.md) — filtering on tags, country, language, sessions
- [Events and ingest](06-events-ingest.md) — custom events and delivery receipts
- [Users and subscriptions](../guides/users-and-subscriptions.md) — the identity model in prose
- [Web push](../guides/web-push.md) — what web push covers today
