# 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 create a duplicate.

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

## Get the device token

The token always comes from the platform — never from Native Notify. Install the package for your stack, get the token, then register it with the call below.

#### Swift

No package to install — APNs is part of UIKit. Ask for permission, register with APNs, and the app delegate hands you the hex device token:

```swift title="AppDelegate.swift"
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
    guard granted else { return }
    DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() }
}

// APNs answers here with the device token:
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let apnsToken = deviceToken.map { String(format: "%02x", $0) }.joined()
    // → register it as tokens.apnsToken
}
```

Full walkthrough: [Swift recipe](/docs/push/recipes/swift).

#### Kotlin

```kotlin title="app/build.gradle.kts"
dependencies {
    implementation(platform("com.google.firebase:firebase-bom:34.0.0"))
    implementation("com.google.firebase:firebase-messaging")
}
```

```kotlin title="FcmToken.kt"
FirebaseMessaging.getInstance().token.addOnSuccessListener { fcmToken ->
    // → register it as tokens.fcmToken
}

// FCM rotates tokens while the app is installed — register the new value again:
class PushService : FirebaseMessagingService() {
    override fun onNewToken(token: String) { /* register tokens.fcmToken again */ }
}
```

Full walkthrough: [Kotlin recipe](/docs/push/recipes/kotlin).

#### Jetpack Compose

Same Firebase package as any Kotlin app, plus `activity-compose` for the Android 13+ permission prompt:

```kotlin title="app/build.gradle.kts"
dependencies {
    implementation(platform("com.google.firebase:firebase-bom:34.0.0"))
    implementation("com.google.firebase:firebase-messaging")
    implementation("androidx.activity:activity-compose:1.9.2")   // permission launcher
}
```

```kotlin title="HomeScreen.kt"
@Composable
fun HomeScreen() {
    val askPermission = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { /* granted or not — read the token either way */ }

    LaunchedEffect(Unit) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            askPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
        FirebaseMessaging.getInstance().token.addOnSuccessListener { fcmToken ->
            // → register it as tokens.fcmToken
        }
    }
}
```

Full walkthrough: [Jetpack Compose recipe](/docs/push/recipes/jetpack-compose).

#### React Native

```bash
npm install @react-native-firebase/app @react-native-firebase/messaging
cd ios && pod install
```

```js title="push.js"
import messaging from "@react-native-firebase/messaging";
import { Platform } from "react-native";

await messaging().requestPermission();

// iOS: the raw APNs token (null until APNs has delivered it).
// Android: the FCM registration token.
const token = Platform.OS === "ios"
  ? await messaging().getAPNSToken()
  : await messaging().getToken();
// → tokens.apnsToken on iOS, tokens.fcmToken on Android

// Rotations arrive here:
messaging().onTokenRefresh((next) => { /* register again */ });
```

Full walkthrough: [Bare React Native recipe](/docs/push/recipes/react-native).

#### Expo

```bash
npx expo install expo-notifications
```

```js title="push.js"
import * as Notifications from "expo-notifications";

const { type, data: token } = await Notifications.getDevicePushTokenAsync();
// type "ios"     → the raw APNs token          → tokens.apnsToken
// type "android" → the FCM registration token → tokens.fcmToken
```

This is the push path from an Expo app, and it needs a development or EAS build — not Expo Go. The SDK path (`registerNNPushToken()`) is [Expo Push (Classic)](/docs/setup/installation); the full walkthrough here is the [Expo recipe](/docs/push/recipes/expo).

#### Flutter

```bash
flutter pub add firebase_core firebase_messaging
```

```dart title="push.dart"
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';

await FirebaseMessaging.instance.requestPermission();

// iOS: the raw APNs token (null until APNs has delivered it).
// Android: the FCM registration token.
final String? token = Platform.isIOS
    ? await FirebaseMessaging.instance.getAPNSToken()
    : await FirebaseMessaging.instance.getToken();
// → tokens.apnsToken on iOS, tokens.fcmToken on Android

FirebaseMessaging.instance.onTokenRefresh.listen((next) {
  // register the new token again
});
```

Full walkthrough: [Flutter recipe](/docs/push/recipes/flutter).

#### Web

No package — the browser's own `PushManager`. Read the app's VAPID public key, subscribe from a click, and keep the subscription JSON:

```js title="web-push.js"
const { vapid } = await fetch(
  `https://app.nativenotify.com/api/universal/web-push/keys/${appId}/${webKey}`  // the publishable web key
).then((r) => r.json());

await Notification.requestPermission();   // must come from a click — Safari, Firefox

const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(vapid.publicKey),
});
// → register subscription.toJSON() as the webPush field, with platform: "web"
```

Full walkthrough — service worker, VAPID and rotation: [Web Push](/docs/push/web-push).

## Request

#### cURL

```bash
curl -X POST https://app.nativenotify.com/api/universal/device/register \
  -H "Content-Type: application/json" \
  -d '{
    "appId": 123,
    "appToken": "yourAppToken",
    "deviceId": "A1B2C3D4-install-key",
    "platform": "ios",
    "subscriberId": "user_8241",
    "appVersion": "2.4.0",
    "timezone": "America/New_York",
    "tokens": { "apnsToken": "0c9d8e…" }
  }'
```

#### Node.js

```js
const res = await fetch("https://app.nativenotify.com/api/universal/device/register", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    appId: 123,
    appToken: "yourAppToken",
    deviceId: "A1B2C3D4-install-key",   // your stable per-install key
    platform: "ios",
    subscriberId: "user_8241",          // optional — omit for an anonymous device
    appVersion: "2.4.0",
    timezone: "America/New_York",
    tokens: { apnsToken: "0c9d8e…" },
  }),
});
if (res.status !== 201) throw new Error(`registration failed (${res.status})`);
```

#### Python

```python
import requests

res = requests.post(
    "https://app.nativenotify.com/api/universal/device/register",
    json={
        "appId": 123,
        "appToken": "yourAppToken",
        "deviceId": "A1B2C3D4-install-key",
        "platform": "ios",
        "subscriberId": "user_8241",  # optional
        "tokens": {"apnsToken": "0c9d8e…"},
    },
    timeout=30,
)
res.raise_for_status()  # 201 = registered
```

| Field               | Required         | Notes                                                                                                                                                                                      |
| ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `appId`, `appToken` | yes              | Your app's pair from the dashboard. For a **web** device you may send the app's **publishable web key** (`nnweb_…`) in the `appToken` field instead — see [App Keys](/docs/push/app-keys). |
| `deviceId`          | yes              | Your stable per-install key. Max 200 chars. Choose it once and keep it.                                                                                                                    |
| `platform`          | yes              | `"ios"`, `"android"` or `"web"`.                                                                                                                                                           |
| `subscriberId`      | no               | Your logged-in user's id (max 256 chars, numbers accepted and coerced). Omit it for an anonymous device.                                                                                   |
| `appVersion`        | no               | Trimmed, capped at 50 chars.                                                                                                                                                               |
| `timezone`          | no               | IANA zone, trimmed, capped at 60 chars.                                                                                                                                                    |
| `environment`       | no               | `"production"` (default), `"staging"` or `"development"` — see [Environments](/docs/push/environments).                                                                                    |
| `framework`         | no               | A label for your stack (`"Flutter"`, `"Unity (C#)"`, …), max 60 chars.                                                                                                                     |
| `tokens.apnsToken`  | one of the three | iOS only. Hex device token, 64+ characters. Aliases: `apns_token`, `apnToken`.                                                                                                             |
| `tokens.fcmToken`   | one of the three | Android only. FCM registration token, 32+ characters. Alias: `fcm_token`.                                                                                                                  |
| `webPush`           | one of the three | The browser `PushSubscription` JSON — `{ endpoint, keys: { p256dh, auth }, expirationTime? }` — with `platform: "web"`. Alias: `web_push`. See [Web Push](/docs/push/web-push).            |

At least one token is required. Token values are capped at 400 characters (web endpoints at 1000), and **Expo push tokens are rejected** here (`unsupported_token_type`) — that is the [Expo path](/docs/setup/installation).

## 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",
    "environment": "production",
    "createdAt": "2026-09-20T14:02:11.000Z",
    "lastSeenAt": "2026-09-23T09:10:44.000Z"
  },
  "tokens": { "apnsToken": "registered" }
}
```

`tokens` reports, per token you sent, what happened: `"registered"` (not seen before), `"refreshed"` (already on this device) or `"reassigned"` (the token moved here from another device).

## Rules worth knowing

- **Re-registering never duplicates.** The unique keys are `(app, deviceId)` for devices and `(app, token type, token)` for tokens.
- **Omitted optional fields keep their stored value** — sending `appVersion` alone never wipes the subscriber id or the environment. Re-registering without `subscriberId` does not detach the device from its subscriber; deregister it instead.
- **Token rotation is normal.** When APNs/FCM hands you a new token, register again — same `deviceId`, new value.
- **Platform pairing is enforced:** `apnsToken` from an `ios` device, `fcmToken` from an `android` device, `webPush` from a `web` device — anything else is `400 token_platform_mismatch`.
- **A registration clears the token's dead flag.** Failure strikes are stored per device, so re-registering the same device keeps its history; a token that moves to a different device is treated as fresh.

## Deregister

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

#### cURL

```bash
curl -X POST https://app.nativenotify.com/api/universal/device/deregister \
  -H "Content-Type: application/json" \
  -d '{ "appId": 123, "appToken": "yourAppToken", "deviceId": "A1B2C3D4-install-key" }'
```

#### Node.js

```js
const res = await fetch("https://app.nativenotify.com/api/universal/device/deregister", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    appId: 123,
    appToken: "yourAppToken",
    deviceId: "A1B2C3D4-install-key",
  }),
});
const { removed } = await res.json();   // removed: false when the device was already gone
```

#### Python

```python
import requests

removed = requests.post(
    "https://app.nativenotify.com/api/universal/device/deregister",
    json={"appId": 123, "appToken": "yourAppToken", "deviceId": "A1B2C3D4-install-key"},
    timeout=30,
).json()["removed"]  # False when the device was already gone
```

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

A **hard delete** of the device row and its tokens, and idempotent: an unknown `deviceId` still answers `200` with `"removed": false`, so a logout that runs twice never fails. Call it when a user logs out, or to stop pushes for an install.

## Errors

Errors use the envelope `{ "error": { "code", "message", "field"? } }`. Every code below answers `400`, except `environment_disabled` (`403`):

| Code                                          | Meaning                                                                                                                                                                                                 |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_body`                                | The body is not a JSON object.                                                                                                                                                                          |
| `missing_field`                               | `appId`, `deviceId` or `platform` is missing.                                                                                                                                                           |
| `invalid_field`                               | `appId` is not a positive integer.                                                                                                                                                                      |
| `invalid_platform`                            | `platform` is not `ios`, `android` or `web`.                                                                                                                                                            |
| `invalid_device_id` / `invalid_subscriber_id` | Too long or wrong type.                                                                                                                                                                                 |
| `invalid_tokens`                              | `tokens` is not an object like `{ apnsToken, fcmToken }`.                                                                                                                                               |
| `no_tokens_provided`                          | No native token and no `webPush` subscription was sent.                                                                                                                                                 |
| `invalid_token_format`                        | Token does not match its shape, or is over 400 chars.                                                                                                                                                   |
| `invalid_web_push_endpoint`                   | `webPush.endpoint` is missing, not a URL, not https, or over 1000 chars.                                                                                                                                |
| `invalid_web_push_keys`                       | `p256dh` is not a base64url 65-byte key, or `auth` is not a base64url 16-byte secret.                                                                                                                   |
| `invalid_web_push`                            | `webPush` is not an object, or `expirationTime` is malformed.                                                                                                                                           |
| `token_platform_mismatch`                     | An APNs token from an Android device, an FCM token from an iOS device, or a `webPush` from a non-web device.                                                                                            |
| `unsupported_token_type`                      | An Expo push token was sent — use the universal tokens instead.                                                                                                                                         |
| `invalid_environment`                         | `environment` is not `production`, `staging` or `development`.                                                                                                                                          |
| `invalid_framework`                           | `framework` is not a string of at most 60 characters.                                                                                                                                                   |
| `environment_disabled`                        | `403` — that environment is switched off for this app; nothing was written.                                                                                                                             |
| `app_not_found`                               | The app id is unknown to a dashboard-session or MCP-token caller. With an `appToken` in the body, an unknown app id or a wrong token answers `401` with a plain-text message, like the rest of the API. |

Registered? Prove it with a [test send](/docs/push/verification) to that `deviceId`, or read the app's counts with [`/api/universal/health`](/docs/push/verification).
