# Users and subscriptions


OpenPush separates the **person** from the **device**. A *user* is someone you know by an external ID; a *subscription* is one addressable install — a phone, a tablet, a browser — holding one push token. One user commonly owns several subscriptions, and a subscription can exist with no user attached at all.

Everything about targeting, identity, opt-out and delivery statistics rests on that split, so it is worth getting exactly right.

## When to use

Read this before wiring `login` into your app, before writing a segment that filters on tags, and before interpreting a status number in the console or an export.

## The model

| Concept | What it is | Identified by |
|---|---|---|
| **Subscription** | One device or browser install with one push token | Its own subscription id, plus the token |
| **User** | The person those installs belong to | Your `external_id` |
| **External ID** | Your own user identifier — the id your backend already uses | A string you choose |
| **Alias** | A second label the server can address the same user by | A `label: id` pair |
| **Tag** | A string key/value on the user, used by segments | Key name |

A subscription with no external ID is **anonymous**: perfectly targetable, countable, and sendable, just not tied to a person. Attaching an external ID later does not create a new subscription; it claims the existing one.

### Tags

Tags are string-to-string, on every SDK, with no numeric, boolean or date variants anywhere. Two rules follow from that:

- **An empty string means delete.** `""` is how "remove this tag" is distinguished from "leave it alone".
- **The whole map rides every registration.** A token refresh cannot lose your tags, and a removed key keeps riding as `""` for the rest of the session so a re-registration cannot resurrect it.

Segments filter on tags; see [segments.md](segments.md).

### Aliases

An alias is a second addressable label beside the external ID — a CRM id, a loyalty number, a player id.

- The cap is **10 pairs per user**.
- The label `external_id` is **reserved** and refused.
- Refusals do not fail the request. A registration that includes an over-cap or reserved label returns `200` with an `aliases_refused` note listing the labels it declined, and the rest of the registration proceeds.
- Deleting an alias, like a tag, is an empty-string value.

## Registering a subscription

Devices register themselves through the SDK. The underlying call is:

```bash
curl -X POST https://app.openpush.ai/v1/apps/app_3f9c/subscriptions \
  -H "X-OP-SDK-Key: $OPENPUSH_SDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "token": "fN8…",
        "platform": "ios",
        "external_id": "user-7",
        "external_id_auth_hash": "9c1f…",
        "tags": {"plan": "pro", "seats": "3"},
        "aliases": {"crm_id": "c-42"},
        "language": "en",
        "timezone": "Europe/London"
      }'
```

```json
{"id":"sub_01hq…","app":"app_3f9c","status":1,"status_name":"Subscribed","created":false}
```

It is an **idempotent upsert keyed on the token**, so re-registering the same device updates it rather than duplicating it. `platform` defaults to `android` when omitted — a real trap for web integrations, which must send `"platform": "web"` explicitly.

The response can also carry `not_collected` (categories dropped by a collection switch), `aliases_refused`, and `identity_rejected`. The complete body reference is in [../api-handbook/03-subscriptions-users.md](../api-handbook/03-subscriptions-users.md).

Registering also has side effects worth knowing: it may count a session, it bumps the user's local-hour activity histogram (which feeds [best-hour delivery](best-hour-delivery.md)), and it fires the journey session trigger.

## The login flow, end to end

**In the app**

```
login(externalId, authHash)  →  the SDK stores the identity and re-registers with it
logout()                     →  the SDK re-registers with a present-and-empty external_id
```

A `login` issued before a push token exists is not lost: the identity is stored and applied to the next registration, including one caused by a token refresh.

**On the wire**

The `external_id` field is tri-state, and the distinction is load-bearing:

| `external_id` in the body | Meaning |
|---|---|
| Absent | No claim — leave whatever identity the subscription already has |
| A non-empty string | Claim this subscription for that user |
| Present and empty (`""`) | **Detach** — this is what logout means |

**On your server**

If identity verification is on, your backend computes the auth hash and hands it to the app. The app never computes it.

**Signing out cleanly**

`logout()` detaches the identity but does not delete the subscription or the token: the device stays registered and reachable, just anonymous. If your app supports account switching, retire the previous account's delivery address on your side before linking a new one — otherwise two accounts end up sharing one install's history.

## Identity verification

Identity verification is an optional per-app setting. Turn it on and OpenPush stops taking a device's word for who it is.

**How it works.** When the setting is on, any registration that claims an `external_id`, `tags` or `aliases` must also send:

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

computed **on your server**. The client never sees the REST key. The hash is checked against **every active REST API key** for the app, so multiple REST keys are interchangeable and a rotation does not invalidate hashes generated moments earlier.

**The failure mode is a downgrade, not a rejection.** This is the single most important behavior on this page. A registration with a missing or wrong hash is **not** rejected:

1. The identity fields — external ID, tags, aliases — are stripped.
2. The device still registers, **anonymously**.
3. Push delivery to that device is unaffected.
4. The response carries an `identity_rejected` note explaining why.

The reasoning is operational: rejecting would break every device on a released binary the moment an admin flips a switch or a backend deploys a hash bug. Silently succeeding would be worse, so the device is registered but the claim is refused and named.

Every SDK surfaces this as a distinct result — never as success. The Android and iOS SDKs report `IdentityRejected(note)`; Unity reports `Success == false` with a non-null `IdentityRejectedNote`. Treat it as a failure in your own UI: do not show the user as identified.

**Hash lifetime.** The auth hash is a bearer proof of identity. No SDK persists it — the external ID survives a cold start but the hash does not, so call `login` again at launch with a freshly supplied hash.

Turning the setting on or off is a console action on the app's settings.

## Subscription status

Status is a single integer that answers "should this subscription receive a push, and if not, why not". Positive means yes.

| Status | Name | Written by | Meaning |
|---|---|---|---|
| any positive up to `511` | Subscribed | Device | Targetable. On iOS the value is an authorization bitmask |
| `1` | Subscribed | Device | The plain "yes" that Android and Unity report |
| `0` | Never Subscribed | Device | Permission not granted |
| `-2` | Unsubscribed | Device or provider | The user opted out in your app, or a provider rejected the token as bad |
| `-10` | Uninstalled | **Provider only** | The push provider reported the token gone — uninstalled or expired |
| `-18` | Never Prompted | Device | The permission prompt has not been shown |
| `-19` | Never Answered | Device | The prompt was shown and dismissed without an answer |
| `-22` | Dashboard Disabled | **Console only** | An operator disabled it |
| `-31` | Disabled via REST API | **API only** | Disabled through an admin call |
| `-99` | Never Subscribed | Legacy | A value seen in older exports |

### The iOS authorization bitmask

On iOS, a positive status is a bitmask, not a rank:

| Bit | Meaning |
|---|---|
| `1` | Badge |
| `2` | Sound |
| `4` | Alert |
| `8` | CarPlay |
| `16` | Critical |
| `32` | App settings |
| `64` | **Provisional** |
| `128` | Announcement |
| `256` | Time sensitive |

Bits combine, and the maximum accepted value is `511`. **Provisional is bit `64`, and it is a positive value** — a provisionally authorized device is subscribed and fully targetable, receiving notifications quietly to Notification Center. There is no negative "provisional" code.

### What a device may and may not send

A device may report any positive value up to `511`, or `0`, `-2`, `-18`, `-19`. It may **not** claim these, and an attempt returns `400` naming the owner of the code:

- `-10` — the push provider writes this, not a device
- `-22` — the console writes this
- `-31` — the admin API writes this
- `-50` — not a status code at all; provisional is bit `64`
- Anything above `511`

The status field must be a genuine JSON integer. A float, a boolean or a numeric string is rejected.

Omitting `status` entirely leaves the stored value unchanged — which is what a registration that only updates tags should do.

## Lifecycle

A subscription's life runs like this:

1. **Created** at first registration, with whatever status the device reports. Often `-18` or `0` before the prompt, then a positive value after a grant.
2. **Updated** on every later registration — token refresh, permission change, tag write, login, logout. Same row, keyed on the token.
3. **Opted out** to `-2` when the user turns push off in your app. The subscription and token are kept; the state is durable across process death and reversible with an opt-in.
4. **Marked unreachable** to `-10` when APNs or FCM reports the token gone, or `-2` when APNs reports a bad device token. **Only the provider writes these two**, and only these two provider outcomes ever change a status.
5. **Disabled** to `-22` or `-31` by an operator or an admin call.

Two guarantees hold through all of it:

- A configuration fault — a bad APNs key, a failed OAuth refresh — **stops the fan-out and touches zero subscriptions**. A broken credential can never mass-unsubscribe your audience.
- A departure date is never moved forward. Once a subscription is marked unsubscribed, a later failure does not rewrite when that happened.

Retryable provider responses (`429`, `5xx`, transport errors) are recorded on the delivery row only and never on the subscription.

## Sessions

A session is an app-foreground observation. SDKs send one automatically on cold launch and on foreground transitions, throttled to at most one request per 30 minutes and persisted across restarts.

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

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

`counted` is `true` only when more than the server's session gap — 30 minutes by default — has passed since the user's last session. An uncounted ping still updates the subscription's last-seen time.

A counted session increments the user's session count, sets their last-session time, and bumps their local-hour activity histogram. Together with first-ever clicks, that histogram is the training signal behind [best-hour delivery](best-hour-delivery.md). A device that never posts sessions still receives push, but contributes nothing to per-user send-time prediction.

## Reading users and subscriptions

Both list routes take the REST API key.

```bash
curl "https://app.openpush.ai/v1/apps/app_3f9c/users?limit=100" \
  -H "X-OP-API-Key: $OPENPUSH_REST_KEY"

curl "https://app.openpush.ai/v1/apps/app_3f9c/subscriptions?search=user-7&order=id" \
  -H "X-OP-API-Key: $OPENPUSH_REST_KEY"
```

- `search` on users covers the external ID and the raw tag data; on subscriptions it covers token, external ID and subscription id.
- `limit` defaults to 100 for users and 200 for subscriptions, with a hard ceiling of 1000 on both.
- **Push tokens are truncated** to the first 20 characters on both list routes. The full token appears only in the admin NDJSON export.
- `?test=1` on the subscriptions route restricts the result to test devices.

### Pagination

There are two modes, chosen explicitly:

| Mode | How to ask | Behavior |
|---|---|---|
| Newest first | No `cursor`, no `order` | Ordered by most recent activity, no cursor returned — the ordering is neither unique nor stable, so a cursor would lie |
| Keyset walk | `?order=id`, or any `?cursor=` | Ascending on the primary key with an opaque cursor, stable across a table that is changing under you |

A short page means the walk is finished. A malformed cursor is a `400`. Offset pagination is deliberately not offered, because it silently skips rows on a moving table.

## Test subscriptions

You can nominate a device as a test subscription. Test devices **ignore quiet hours and the frequency cap on every send**, and anything currently held for quiet hours on that device is released the moment you add it. That makes them right for verifying a campaign and wrong for measuring one. See [../api-handbook/03-subscriptions-users.md](../api-handbook/03-subscriptions-users.md).

## Data collection

Three fields are gated by per-app collection switches: advertising id, location (latitude/longitude), and email. When a switch is off, values for that category are **dropped, not rejected** — a `400` would break every device on a released binary the instant an admin flips the switch. The dropped category names come back in `not_collected`.

Turning a switch off also **erases the data already stored** for that category, and the settings response reports how many rows were purged.

IP storage is a server-wide setting with three modes: store the full address, store a truncated one, or store nothing. The current mode is reported on the app's settings.

## Limits

- Aliases: 10 per user, `external_id` reserved.
- Tag values are strings only.
- Status must be an integer, at most `511`, and cannot claim a provider-owned or operator-owned code.
- List routes truncate tokens; only the NDJSON export carries full ones.
- No offset pagination; page size is capped at 1000.
- Identity verification downgrades rather than rejecting — build your UI around that.
- The auth hash is never persisted by any SDK.
- There is no rate limit on registration or on sessions, but the default request body cap applies.
- Email, SMS and other channels are not deliverable platforms in OpenPush; the deliverable platforms are `ios`, `android` and `web`.

## FAQ

**If a user installs on three devices, is that one user or three?**
One user, three subscriptions. Attach the same external ID from each install.

**What happens to tags when I call `logout`?**
The subscription is detached from the identified user and moved to a fresh anonymous one, so it no longer carries that person's tags — tags live on the user record. The subscription keeps its own state, including its status and token, and the SDK keeps its pending tag map for the process session.

**A device shows as anonymous even though I called `login`. Why?**
Almost always identity verification with a missing or wrong auth hash. Check the `identity_rejected` note; the device registered, the claim did not.

**Can I set a subscription to `-10` myself?**
No. `-10` is provider-written. `-2` is the code for a user-initiated opt-out.

**Why did my whole audience *not* get marked unreachable when my APNs key expired?**
By design. Configuration faults stop the send and change nothing; only genuine per-device provider verdicts change a status.

**Does a provisional iOS device count as subscribed?**
Yes. Provisional is bit `64` of a positive status.

**Why is my web device showing platform `android`?**
`platform` defaults to `android` when the field is omitted. Send it explicitly.

## Related

- [sdk-android.md](sdk-android.md)
- [sdk-ios.md](sdk-ios.md)
- [sdk-unity.md](sdk-unity.md)
- [web-push.md](web-push.md)
- [segments.md](segments.md)
- [sending-messages.md](sending-messages.md)
- [Best-hour delivery](best-hour-delivery.md)
- [security-and-limits.md](security-and-limits.md)
- [import-export.md](import-export.md)
- [../api-handbook/03-subscriptions-users.md](../api-handbook/03-subscriptions-users.md)
- [../api-handbook/01-apps-keys-settings.md](../api-handbook/01-apps-keys-settings.md)
