Register an Expo app's native APNs and FCM tokens with Native Notify, show images, or use the native-notify SDK on Expo's push service.
Two routes
Sends here go through your own Firebase project and Apple key. For Expo's push service — the native-notify SDK with inbox components, receipts and analytics — follow Expo setup. The routes are independent per app.
1. Set up your Expo project
npx expo install expo-notifications expo-application expo-task-manager
Android needs your Firebase config, from the same Firebase project whose service-account JSON you save in step 4 — or sends fail with SENDER_ID_MISMATCH:
{
"expo": {
"android": { "googleServicesFile": "./google-services.json" }
}
}
Expo Go
Use a development or EAS build, with push enabled on iOS. In Expo Go, getDevicePushTokenAsync() throws on Android (SDK 53+), and on iOS it returns Expo Go's token, not yours — your Apple key can't deliver to it (DeviceTokenNotForTopic).
2. Register the device
import * as Notifications from "expo-notifications";
import * as Application from "expo-application";
import { Platform } from "react-native";
const REGISTER_URL = "https://app.nativenotify.com/api/universal/device/register";
// Stable across launches: the vendor id on iOS, ANDROID_ID on Android.
async function stableDeviceId() {
const id = Platform.OS === "ios" ? await Application.getIosIdForVendorAsync() : Application.getAndroidId();
if (!id) throw new Error("No device id is available yet — retry on the next launch.");
return id;
}
let lastRegisteredToken = null;
async function postRegistration({ appId, appToken, subscriberId }, devicePushToken) {
const token = String(devicePushToken.data);
if (token === lastRegisteredToken) return null; // already registered this session
const res = await fetch(REGISTER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appId,
appToken,
deviceId: await stableDeviceId(),
platform: Platform.OS,
subscriberId, // your logged-in user's id; omit for an anonymous device
tokens: Platform.OS === "ios" ? { apnsToken: token } : { fcmToken: token },
}),
});
if (res.status !== 201) {
// 400/403 answer { error: { code, message } }; a wrong appId/appToken answers 401 as plain text.
throw new Error(`Native Notify register failed (${res.status}): ${await res.text()}`);
}
lastRegisteredToken = token;
return res.json(); // { ok, device, tokens }
}
export async function registerUniversalDevice(args) {
if (Platform.OS !== "ios" && Platform.OS !== "android") return null;
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") return null;
// The NATIVE token: APNs on iOS, FCM on Android (development/EAS builds only).
const devicePushToken = await Notifications.getDevicePushTokenAsync();
return postRegistration(args, devicePushToken);
}
// Re-register when the native token rotates. Use the token the listener hands
// you: calling getDevicePushTokenAsync() inside this listener re-fires it.
export function watchUniversalToken(args) {
const subscription = Notifications.addPushTokenListener((devicePushToken) => {
postRegistration(args, devicePushToken).catch((err) => console.warn("Native Notify:", err));
});
return () => subscription.remove();
}
Call registerUniversalDevice({ appId, appToken, subscriberId }) on every launch, and keep watchUniversalToken(...) subscribed so a rotated token registers again.
3. Show pushes
While the app is closed or in the background, expo-notifications shows a push itself. While the app is open it shows nothing until a notification handler is set, and on Android it never hands a push without a picture (an FCM data message) to that handler. This file sets the handler and, on Android, shows such a push through an expo-task-manager task:
// Native Notify — shows pushes while the app is open.
//
// expo-notifications shows a push while the app is closed or in the background, but not while it is open:
// it shows nothing until a notification handler is set, and on Android a push without a picture is an FCM
// *data* message that it never hands to the handler. Its own documented route for those is a task:
// expo-task-manager runs it for every push, in every app state. This one turns a data-only push into a
// local notification while the app is open (closed or in the background, expo-notifications shows the push
// itself, so the task does nothing then).
//
// Packages (run in your terminal):
// npx expo install expo-notifications expo-task-manager
//
// Import it once at the top of your app's entry file (index.js / index.ts), before registerRootComponent —
// the task has to be defined before the app mounts:
// import './native-notify/nativeNotifyForeground';
import { AppState, Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import * as TaskManager from 'expo-task-manager';
const TASK = 'native-notify-foreground-push';
// Without a handler expo-notifications shows nothing while the app is open.
Notifications.setNotificationHandler({
handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false }),
});
if (Platform.OS === 'android') {
TaskManager.defineTask(TASK, async (/** @type {{ data: any }} */ { data }) => {
// Tapping an action button runs the task too; only a received push matters here.
if (!data || 'actionIdentifier' in data) return;
// A push with a picture has a notification block: expo-notifications shows it through the handler, so skip it.
if (data.notification) return;
// Closed or in the background expo-notifications shows a data-only push itself; showing it here too would double it.
if (AppState.currentState !== 'active') return;
const payload = data.data || {};
if (!payload.title) return;
/** @type {Record<string, unknown>} */
let pushData = {};
try {
pushData = JSON.parse(payload.dataString || '{}'); // your pushData
} catch {
pushData = {};
}
await Notifications.scheduleNotificationAsync({
content: { title: String(payload.title), body: payload.message ? String(payload.message) : undefined, data: pushData },
trigger: null,
});
});
Notifications.registerTaskAsync(TASK).catch(() => {});
}
Import it once at the top of your entry file (index.js or index.ts), before registerRootComponent, so the task exists before the app mounts:
import "./native-notify/nativeNotifyForeground";
An expo-router app starts from expo-router/entry: create an index.js that imports this file and then expo-router/entry, and point main in package.json at it.
Already call Notifications.setNotificationHandler somewhere? Delete that call from this file and keep yours.
When the user taps a push, expo-notifications hands your pushData to your listeners as notification.request.content.data.
4. Save credentials in Native Notify
Sends deliver only with the app's push credentials: the Firebase service-account JSON (Android) and the .p8 key for the app's bundle id (iOS). In Native Notify, save them under your project's Settings → Push credentials.
5. Send a push with the API
Check one device first with a test send, then send to an audience.
Show images
iOS never downloads a push's image by itself, and an Expo app generates its iOS project, so add the notification service extension as a target for the @bacons/apple-targets config plugin (Expo SDK 53 or newer).
- In your terminal, run
npx expo install @bacons/apple-targets. It adds the plugin to your app config. - In your project, create
targets/notification-service/expo-target.config.json:
{
"type": "notification-service",
"name": "NotificationService",
"bundleIdentifier": ".NotificationService",
"deploymentTarget": "15.1"
}
- Create
targets/notification-service/NotificationService.swiftwith the code from Show images in the Swift recipe. - In your terminal, run
npx expo prebuild -p ios --cleanand run the app, or start a new EAS build.
The plugin writes the extension's Info.plist, embeds the extension in the app and registers it with EAS Build for signing. Its default deploymentTarget is 18.0, which skips the extension on older iPhones. Add your Apple Team ID to your app config as ios.appleTeamId; the plugin warns without it.
On Android, Firebase draws the picture while the app is closed or in the background, and expo-notifications draws it through the handler from step 3 while the app is open. Send a push with a bigPictureURL and look on an iPhone: the Simulator never runs the extension.