Register a device and send your first push notification.
1. Create an app in Native Notify
In Native Notify, click + New project, then open the project's Keys page to copy the app id and app token. Requests carry both — in the body for POSTs, in the URL for GETs.
2. Add push credentials in Native Notify
Android needs an FCM v1 service-account JSON. iOS needs an APNs .p8 key with its Key ID, your Team ID and the app's bundle id — which takes a paid Apple Developer Program membership and a Bundle ID registered with Apple. In Native Notify, save them under Settings → Push credentials — each save is checked live. Web push needs none, so web-only apps can skip to step 3. See Push credentials for where each one comes from.
3. Register a device from your app
POST https://app.nativenotify.com/api/universal/device/register
{
"appId": 123,
"appToken": "yourAppToken",
"deviceId": "A1B2C3D4-install-key",
"platform": "ios",
"tokens": { "apnsToken": "0c9d8e…" }
}
{
"appId": 123,
"appToken": "yourAppToken",
"deviceId": "A1B2C3D4-install-key",
"platform": "android",
"tokens": { "fcmToken": "d7Fk2…" }
}
{
"appId": 123,
"appToken": "yourAppToken",
"deviceId": "A1B2C3D4-install-key",
"platform": "web",
"webPush": {
"endpoint": "https://fcm.googleapis.com/fcm/send/…",
"keys": { "p256dh": "BNc…", "auth": "k9X…" }
}
}
Registration saves the device id you generate once per install and the platform's push token — APNs on iOS, FCM on Android, a PushSubscription on the web. It's an upsert: call it on every launch and whenever the token changes.
Examples
Ask for permission, let APNs hand you the device token, and register it:
import UIKit
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
guard granted else { return }
DispatchQueue.main.async { application.registerForRemoteNotifications() }
}
return true
}
// APNs delivers the device token here.
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let apnsToken = deviceToken.map { String(format: "%02x", $0) }.joined()
Task { await register(apnsToken: apnsToken) }
}
func register(apnsToken: String) async {
var request = URLRequest(url: URL(string: "https://app.nativenotify.com/api/universal/device/register")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: [
"appId": 123,
"appToken": "yourAppToken",
"deviceId": DeviceIdentity.current, // a stable id you persist (Keychain, UserDefaults)
"platform": "ios",
"tokens": ["apnsToken": apnsToken]
])
_ = try? await URLSession.shared.data(for: request) // 201 = registered
}
}
Stable deviceId and tap handling: Swift recipe.
FCM hands you the token; onNewToken gives you every rotation:
import com.google.firebase.messaging.FirebaseMessagingService
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import org.json.JSONObject
import java.util.UUID
class NativeNotifyPush : FirebaseMessagingService() {
override fun onNewToken(token: String) = register(token)
private fun register(fcmToken: String) {
val prefs = getSharedPreferences("native-notify", MODE_PRIVATE)
val deviceId = prefs.getString("deviceId", null)
?: UUID.randomUUID().toString().also { prefs.edit().putString("deviceId", it).apply() }
val body = JSONObject()
.put("appId", 123)
.put("appToken", "yourAppToken")
.put("deviceId", deviceId) // the same id on every launch
.put("platform", "android")
.put("tokens", JSONObject().put("fcmToken", fcmToken))
val request = Request.Builder()
.url("https://app.nativenotify.com/api/universal/device/register")
.post(body.toString().toRequestBody("application/json".toMediaType()))
.build()
OkHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) { response.close() } // 201 = registered
override fun onFailure(call: Call, e: IOException) { /* retry on the next launch */ }
})
}
}
Android pushes arrive as FCM data messages, so your app shows the notification — see the Kotlin recipe.
Same Firebase token as Kotlin, plus the Android 13+ permission prompt:
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.google.firebase.messaging.FirebaseMessaging
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import org.json.JSONObject
@Composable
fun HomeScreen() {
val context = LocalContext.current
// Android 13+ will not show a notification until the permission is granted.
val askPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
askPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
FirebaseMessaging.getInstance().token.addOnSuccessListener { fcmToken ->
register(context, fcmToken)
}
}
}
private fun register(context: Context, fcmToken: String) {
val prefs = context.getSharedPreferences("native-notify", Context.MODE_PRIVATE)
val deviceId = prefs.getString("deviceId", null)
?: UUID.randomUUID().toString().also { prefs.edit().putString("deviceId", it).apply() }
val body = JSONObject()
.put("appId", 123)
.put("appToken", "yourAppToken")
.put("deviceId", deviceId) // the same id on every launch
.put("platform", "android")
.put("tokens", JSONObject().put("fcmToken", fcmToken))
val request = Request.Builder()
.url("https://app.nativenotify.com/api/universal/device/register")
.post(body.toString().toRequestBody("application/json".toMediaType()))
.build()
OkHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) { response.close() } // 201 = registered
override fun onFailure(call: Call, e: IOException) { /* retry on the next launch */ }
})
}
Gradle packages and screen wiring: Jetpack Compose recipe.
@react-native-firebase/messaging gives you the token and every rotation:
import {
getAPNSToken,
getMessaging,
getToken,
onTokenRefresh,
requestPermission,
} from "@react-native-firebase/messaging";
import { Platform } from "react-native";
import { getOrCreateDeviceId } from "./device-id"; // persist one id (AsyncStorage, Keychain)
export async function registerDevice() {
await requestPermission(getMessaging());
// iOS: the raw APNs token (null until APNs has delivered it).
// Android: the FCM registration token.
const token = Platform.OS === "ios"
? await getAPNSToken(getMessaging())
: await getToken(getMessaging());
if (!token) return;
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: await getOrCreateDeviceId(),
platform: Platform.OS, // "ios" | "android"
tokens: Platform.OS === "ios" ? { apnsToken: token } : { fcmToken: token },
}),
}); // 201 = registered
}
// Every rotation registers the new token against the same deviceId.
onTokenRefresh(getMessaging(), () => registerDevice());
Packages and the Notifee display handler: React Native recipe.
expo-notifications returns the platform's own token:
import * as Notifications from "expo-notifications";
import { getOrCreateDeviceId } from "./device-id"; // persist one id (AsyncStorage)
export async function registerDevice() {
const { type, data: token } = await Notifications.getDevicePushTokenAsync();
// type "ios" → the raw APNs token
// type "android" → the FCM registration token
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: await getOrCreateDeviceId(),
platform: type, // "ios" | "android"
tokens: type === "ios" ? { apnsToken: token } : { fcmToken: token },
}),
}); // 201 = registered
}
Needs a development or EAS build — Expo Go can't receive real pushes. Install with npx expo install expo-notifications. See the Expo recipe.
firebase_messaging hands you the token; onTokenRefresh gives you every rotation:
import 'dart:convert';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:http/http.dart' as http;
Future<void> registerDevice() async {
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();
if (token == null) return;
final response = await http.post(
Uri.parse('https://app.nativenotify.com/api/universal/device/register'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'appId': 123,
'appToken': 'yourAppToken',
'deviceId': await getOrCreateDeviceId(), // persist one id (shared_preferences)
'platform': Platform.isIOS ? 'ios' : 'android',
'tokens': Platform.isIOS ? {'apnsToken': token} : {'fcmToken': token},
}),
); // 201 = registered
// Every rotation registers the new token against the same deviceId.
FirebaseMessaging.instance.onTokenRefresh.listen((next) => registerDevice());
}
Install with flutter pub add firebase_core firebase_messaging http. See the Flutter recipe.
@capacitor/push-notifications hands you the token on the registration event, and again on every rotation:
import { Capacitor } from "@capacitor/core";
import { Device } from "@capacitor/device";
import { PushNotifications } from "@capacitor/push-notifications";
export async function registerDevice() {
await PushNotifications.requestPermissions();
await PushNotifications.addListener("registration", async ({ value }) => {
// iOS: the raw APNs token, in uppercase hex. Android: the FCM registration token.
const platform = Capacitor.getPlatform(); // "ios" | "android"
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: (await Device.getId()).identifier,
platform,
tokens: platform === "ios" ? { apnsToken: value.toLowerCase() } : { fcmToken: value },
}),
}); // 201 = registered
});
await PushNotifications.register();
}
Install with npm install @capacitor/push-notifications @capacitor/device, then run npx cap sync. See the Capacitor recipe.
Ask for permission from a click, subscribe, and register. The web snippet uses the publishable web key (nnweb_…, from your project's Keys page in Native Notify) — safe in page source, unlike the app token:
const API = "https://app.nativenotify.com";
const appId = 123;
const webKey = "nnweb_…"; // publishable web key — register/deregister this one browser
export async function registerWebPush() {
const permission = await Notification.requestPermission(); // must be called from a click
if (permission !== "granted") return;
await navigator.serviceWorker.register("/sw.js"); // the worker below
const registration = await navigator.serviceWorker.ready;
const { vapid } = await fetch(`${API}/api/universal/web-push/keys/${appId}/${webKey}`).then((r) => r.json());
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapid.publicKey),
});
await fetch(`${API}/api/universal/device/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId,
appToken: webKey, // the web key rides in the appToken field
deviceId: getOrCreateDeviceId(),
platform: "web",
webPush: subscription.toJSON(),
}),
});
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
Serve this service worker at your site root (/sw.js):
self.addEventListener("push", (event) => {
const payload = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(payload.title || "Notification", {
body: payload.body || "",
image: payload.image, // the send's bigPictureURL, when one was set
data: payload.data || {}, // your pushData (plus nn_notification_id)
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = new URL(event.notification.data?.url || "/", self.location.origin).href;
event.waitUntil(clients.openWindow(url));
});
Web push needs HTTPS (localhost excepted). On iPhone and iPad the site must be added to the Home Screen first. See Web push.
4. Send a test push with the API
Send to the device you registered. The response says whether APNs or FCM accepted it.
curl -X POST https://app.nativenotify.com/api/universal/test-send \
-H "Content-Type: application/json" \
-d '{
"appId": 123,
"appToken": "yourAppToken",
"title": "Hello from Native Notify",
"message": "If you can read this, APNs/FCM accepted it.",
"deviceId": "YOUR_DEVICE_ID"
}'
const res = await fetch("https://app.nativenotify.com/api/universal/test-send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId: 123,
appToken: "yourAppToken",
title: "Hello from Native Notify",
message: "If you can read this, APNs/FCM accepted it.",
deviceId: "YOUR_DEVICE_ID",
}),
});
const result = await res.json();
console.log(result.result, "via", result.transport); // "accepted" via "apns" — or the reason, in plain English
import requests
result = requests.post(
"https://app.nativenotify.com/api/universal/test-send",
json={
"appId": 123,
"appToken": "yourAppToken",
"title": "Hello from Native Notify",
"message": "If you can read this, APNs/FCM accepted it.",
"deviceId": "YOUR_DEVICE_ID",
},
timeout=30,
).json()
print(result["result"], "via", result["transport"]) # "accepted" via "apns" — or the reason, in plain English
5. Send to everyone with the API
Change the audience to reach every registered device.
curl -X POST https://app.nativenotify.com/api/universal/notifications/send \
-H "Content-Type: application/json" \
-d '{
"appId": 123,
"appToken": "yourAppToken",
"title": "Hello everyone",
"message": "Our first push notification.",
"audience": { "type": "all" }
}'
const res = await fetch("https://app.nativenotify.com/api/universal/notifications/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId: 123,
appToken: "yourAppToken",
title: "Hello everyone",
message: "Our first push notification.",
audience: { type: "all" },
}),
});
const send = await res.json();
console.log(send.accepted, "accepted of", send.attempted);
import requests
send = requests.post(
"https://app.nativenotify.com/api/universal/notifications/send",
json={
"appId": 123,
"appToken": "yourAppToken",
"title": "Hello everyone",
"message": "Our first push notification.",
"audience": {"type": "all"},
},
timeout=30,
).json()
print(send["accepted"], "accepted of", send["attempted"])
Troubleshooting
| Symptom | Likely cause |
|---|---|
401 | Wrong appToken, or a web key (nnweb_…) used from a native device. The web key only belongs in platform: "web" registrations. |
400 invalid_token_format | Not a raw APNs or FCM token. Expo push tokens are rejected — pass the native token (getDevicePushTokenAsync()). |
400 token_platform_mismatch | apnsToken → ios, fcmToken → android, webPush → web. |
Test send refused with a reasonCode | Usually the step 2 credential: bundle id, team id, or sandbox vs production. See Verify delivery. |
| Accepted, but nothing shows on Android | FCM sends data messages, so your app shows them. See your platform guide. |
| Accepted, but nothing shows on iPhone or iPad (web) | Safari delivers web push only to sites added to the Home Screen. |
403 plan_upgrade_required | The plan doesn't include that audience. Premium allows one subscriber or "all", not groups or device lists. See Plans & billing. |
Next steps
- Device registration — every field, response and error code.
- Send notifications — audiences, rich fields and the send report.
- Platform guides — Swift, Kotlin, Jetpack Compose, React Native, Expo, Flutter and Capacitor.