# Device Registration

Register and deregister devices with the framework-agnostic endpoint — deviceId, platform, APNs/FCM tokens, subscriber ids, and the exact rules and error codes.

Registration tells Native Notify "this device can receive pushes, on this token". Call it **on every app launch** (and whenever the push token changes) — it is an idempotent upsert, so repeats are cheap and never duplicate a device.

```text
POST https://app.nativenotify.com/api/universal/device/register
```

## Request

```json
{
  "appId": 123,
  "appToken": "yourAppToken",
  "deviceId": "A1B2C3D4-install-key",
  "platform": "ios",
  "subscriberId": "user_8241",
  "appVersion": "2.4.0",
  "timezone": "America/New_York",
  "tokens": {
    "apnsToken": "9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f009f8a7b6c5d4e3f2a1b0c9d8e"
  }
}
```

| Field               | Required       | Notes                                                                                                                                     |
| ------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `appId`, `appToken` | yes            | Your app's pair from the dashboard.                                                                                                       |
| `deviceId`          | yes            | Your stable per-install key. Max 200 chars. This is the device's identity — choose it once and keep it.                                   |
| `platform`          | yes            | `"ios"` or `"android"`.                                                                                                                   |
| `subscriberId`      | no             | Send your user id to make it an **indie device**; omit (or empty) for a **mass device**. Max 256 chars. Numbers are accepted and coerced. |
| `appVersion`        | no             | Trimmed, capped at 50 chars. Useful in support views.                                                                                     |
| `timezone`          | no             | IANA zone, trimmed, capped at 60 chars.                                                                                                   |
| `tokens.apnsToken`  | one of the two | iOS only. Hex device token, 64+ characters. Aliases: `apns_token`, `apnToken`.                                                            |
| `tokens.fcmToken`   | one of the two | Android only. FCM registration token, 32+ URL-safe characters. Alias: `fcm_token`.                                                        |

At least one token is required. Token values are capped at 400 characters, and **Expo push tokens are rejected** here (`unsupported_token_type`) — that is a different service; see [Universal Push](/docs/universal-push).

## Response — `201`

```json
{
  "ok": true,
  "device": {
    "appId": 123,
    "deviceId": "A1B2C3D4-install-key",
    "platform": "ios",
    "subscriberId": "user_8241",
    "appVersion": "2.4.0",
    "timezone": "America/New_York",
    "createdAt": "2026-09-20T14:02:11.000Z",
    "lastSeenAt": "2026-09-23T09:10:44.000Z"
  },
  "tokens": { "apnsToken": "ok" }
}
```

`tokens` reports per token what happened: `"ok"` (already on this device), `"refreshed"` (value updated), or `"reassigned"` (the token moved here from another device).

## Semantics that matter

- **Re-registering never duplicates.** The unique keys are `(app, deviceId)` for devices and `(app, token type, token)` for tokens. A token only ever exists once per app.
- **First registration wins for ages.** `createdAt` is never bumped by a re-register; `lastSeenAt` refreshes every time.
- **Token rotation is normal.** When APNs/FCM hands you a new token, register again — same `deviceId`, new token value.
- **A registration clears the token's dead flag.** Presenting a token again is proof it is in current use. Failure strikes are stored per device — re-registering the *same* device keeps its history; a token that moves to a different device is treated as fresh (strikes reset).
- **Omitted optional fields keep their stored value** — sending `appVersion` alone never wipes the subscriber id.
- **Platform pairing is enforced:** `apnsToken` from an `ios` device, `fcmToken` from an `android` device.

## Deregister

```text
POST https://app.nativenotify.com/api/universal/device/deregister
```

```json
{ "appId": 123, "appToken": "yourAppToken", "deviceId": "A1B2C3D4-install-key" }
```

```json
{ "ok": true, "deviceId": "A1B2C3D4-install-key", "removed": true, "tokensRemoved": 1 }
```

Deregister is a **hard delete** — the device row is removed and its tokens cascade. It is idempotent: an unknown `deviceId` still answers `200` with `"removed": false`, so a logout that runs twice (or a retried request) never fails. Call it when a user logs out of an indie identity, or when you want to stop pushes for that install.

## Error codes

Errors use the stable envelope `{ "error": { "code", "message", "field"? } }`:

| Code                                          | Meaning                                                                           |
| --------------------------------------------- | --------------------------------------------------------------------------------- |
| `missing_field`                               | `appId`, `deviceId`, `platform` or both tokens are missing.                       |
| `invalid_platform`                            | `platform` is not `ios` or `android`.                                             |
| `invalid_device_id` / `invalid_subscriber_id` | Too long or wrong type.                                                           |
| `no_tokens_provided`                          | Neither `tokens.apnsToken` nor `tokens.fcmToken` was sent.                        |
| `invalid_token_format`                        | Token does not match its shape (hex APNs / URL-safe FCM) or is over 400 chars.    |
| `token_platform_mismatch`                     | An APNs token from an Android device, or an FCM token from an iOS device.         |
| `unsupported_token_type`                      | An Expo push token was sent — use the universal tokens instead.                   |
| `app_not_found`                               | The app id is unknown; a wrong `appToken` answers `401` like the rest of the API. |

## Try it

Register, then immediately prove delivery with a [test send](/docs/universal-push/verification) to that `deviceId`, or check the app's counts through [`/api/universal/health`](/docs/universal-push/verification).
