Register a device with its push token, or remove it.
POST https://app.nativenotify.com/api/universal/device/register
Call it on every app launch and whenever the push token changes. It's an upsert, so repeats never create a duplicate.
Get the device token
The token comes from the platform, never from Native Notify. Get it, then register it with the request below.
No package needed — APNs is part of UIKit. Ask for permission and APNs hands you the hex token:
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
}
See the Swift recipe.
dependencies {
implementation(platform("com.google.firebase:firebase-bom:34.0.0"))
implementation("com.google.firebase:firebase-messaging")
}
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 */ }
}
See the Kotlin recipe.
Same Firebase package as Kotlin, plus activity-compose for the Android 13+ permission prompt:
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
}
@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
}
}
}
See the Jetpack Compose recipe.
npm install @react-native-firebase/app @react-native-firebase/messaging
cd ios && pod install
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 */ });
See the React Native recipe.
npx expo install expo-notifications
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
Needs a development or EAS build, not Expo Go. The registerNNPushToken() SDK path is Expo Push (Classic). Full walkthrough: Expo recipe.
flutter pub add firebase_core firebase_messaging
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
});
See the Flutter recipe.
No package — the browser's own PushManager. Read the VAPID public key, subscribe from a click, and keep the subscription JSON:
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"
Service worker, VAPID and rotation: Web push.
Request
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…" }
}'
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})`);
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 | From the dashboard. A web device may send the publishable web key (nnweb_…) as appToken. See App keys. |
deviceId | yes | Your stable per-install key. Max 200 chars. |
platform | yes | "ios", "android" or "web". |
subscriberId | no | Your logged-in user's id. Max 256 chars; numbers are coerced. Omit for an anonymous device. |
appVersion | no | Max 50 chars. |
timezone | no | IANA zone, max 60 chars. |
environment | no | "production" (default), "staging" or "development". See Environments. |
framework | no | A label for your stack, like "Flutter". Max 60 chars. |
tokens.apnsToken | one token required | iOS only. Hex token, 64+ chars. Aliases: apns_token, apnToken. |
tokens.fcmToken | one token required | Android only. 32+ chars. Alias: fcm_token. |
webPush | one token required | The browser PushSubscription JSON — { endpoint, keys: { p256dh, auth }, expirationTime? } — with platform: "web". Alias: web_push. See Web push. |
Tokens are capped at 400 characters (web endpoints at 1000). Expo push tokens are rejected with unsupported_token_type — that's the Expo path.
Response — 201
{
"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 what happened to each token: "registered" (new), "refreshed" (already on this device) or "reassigned" (moved here from another device).
Rules
- No duplicates. The unique keys are
(app, deviceId)for devices and(app, token type, token)for tokens. - Omitted optional fields keep their stored value. Re-registering without
subscriberIddoesn't detach the device from its subscriber — deregister it instead. - Token rotation is normal. Register again with the same
deviceIdand the new token. - Registering clears a token's dead flag. Failure strikes are kept per device, so a token that moves to another device starts fresh.
Deregister
POST https://app.nativenotify.com/api/universal/device/deregister
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" }'
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
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
{ "ok": true, "deviceId": "A1B2C3D4-install-key", "removed": true, "tokensRemoved": 1 }
Deregistering hard-deletes the device and its tokens. It's idempotent: an unknown deviceId answers 200 with "removed": false. Call it on logout, or to stop pushes to an install.
Errors
Errors use { "error": { "code", "message", "field"? } } and answer 400, except environment_disabled (403).
| Code | Meaning |
|---|---|
invalid_body | The body isn't a JSON object. |
missing_field | appId, deviceId or platform is missing. |
invalid_field | appId isn't a positive integer. |
invalid_platform | platform isn't ios, android or web. |
invalid_device_id, invalid_subscriber_id | Too long or the wrong type. |
invalid_tokens | tokens isn't an object like { apnsToken, fcmToken }. |
no_tokens_provided | No native token and no webPush subscription. |
invalid_token_format | The token doesn't match its shape, or is over 400 chars. |
invalid_web_push_endpoint | webPush.endpoint is missing, not an https URL, or over 1000 chars. |
invalid_web_push_keys | p256dh isn't a base64url 65-byte key, or auth isn't a base64url 16-byte secret. |
invalid_web_push | webPush isn't 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. |
invalid_environment | environment isn't production, staging or development. |
invalid_framework | framework isn't 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 wrong token answers 401 with a plain-text message, like the rest of the API. |
Check a registration with a test send to that deviceId.