React Native SDK
@opennotification/react-native covers iOS and Android from one JavaScript API, and the whole native side ships inside the package: no @react-native-firebase/*, no Notifee, no AsyncStorage. iOS talks to APNs directly through a native module — no Firebase iOS SDK. Android embeds the Kotlin SDK over Firebase Messaging and draws every notification itself; Firebase Messaging comes in through Gradle, not npm.
1 Install
bun add @opennotification/react-native
Then pick one: the Expo plugin or the manual setup.
2 Initialise
import { OpenNotification } from "@opennotification/react-native";
await OpenNotification.init({
endpoint: "https://push.example.com",
appKey: "pk_live_…",
autoRegister: false, // true: ask for permission during init
androidSmallIcon: "ic_notification", // drawable name, monochrome
});
init wires the native listeners and, on Android, gets the FCM token and registers immediately (permission is not needed to receive a token). On iOS nothing is sent until permission is granted. There is no background handler to register in index.js: drawing and tracking are native, so FCM waking the process with no JavaScript in it is fine.
3 Permission, identity, tags, opens
const result = await OpenNotification.requestPermission();
if (!result.ok) console.warn(result.error.code); // permission_denied | no_token | api_error | network_error
await OpenNotification.login("user_123");
await OpenNotification.setTags({ plan: "platinum", streak: 42 });
const off = OpenNotification.onOpen((message) => {
navigate(message.data.screen ?? "Home", message.data); // message.url, message.actionId also available
});
OpenNotification.onReceive((message) => {
if (message.silent) sync(message.data); // background push: nothing was shown, data is the message
});
await OpenNotification.logout();
await OpenNotification.unsubscribe();
await OpenNotification.trackSession(); // optional; AppState "active" does this automatically
onOpen fires for a cold launch too: the native side holds the press until JavaScript is ready.
Sessions are counted for you: start() listens to AppState and pings /session on every return to the foreground; the server applies the 30-minute idle rule, so nothing is double-counted. Silent pushes reach onReceive with message.silent === true — on iOS forward application(_:didReceiveRemoteNotification:fetchCompletionHandler:) to OpenNotification.didReceiveBackground(userInfo:) (see the AppDelegate below) and enable the Remote notifications background mode.
await OpenNotification.permissionStatus() returns granted | denied | provisional | not_determined; OpenNotification.register() re-sends the registration explicitly.
For removeTags / setLanguage use the underlying client: const client = await OpenNotification.init(…); client.removeTags(["streak"]).
Expo config plugin
{
"expo": {
"ios": { "bundleIdentifier": "com.acme.app" },
"android": { "googleServicesFile": "./google-services.json" },
"plugins": [
["@opennotification/react-native", {
"endpoint": "https://push.example.com",
"androidSmallIconPath": "./assets/ic_notification.png"
}]
]
}
}
npx expo prebuild (or an EAS build) then applies everything the manual setup below lists:
| Platform | What the plugin does |
|---|---|
| iOS | aps-environment entitlement, remote-notification background mode, the four AppDelegate callbacks, a NotificationService extension target with OpenNotificationApiUrl = endpoint in its Info.plist. |
| Android | POST_NOTIFICATIONS permission, copies androidSmallIconPath to res/drawable/ic_notification.png. google-services.json and the Gradle plugin come from Expo's own android.googleServicesFile. |
| Option | Default | |
|---|---|---|
endpoint | — | API base URL for the extension. |
iosServiceExtension | true | Set false to skip the extension (no delivered on iOS then). |
iosServiceExtensionName | NotificationService | Target name; bundle id is <bundleIdentifier>.<name>. |
iosDeploymentTarget | app's | |
iosApsEnvironment | development | EAS sets the right one at build time. |
androidSmallIconPath | — | Monochrome PNG, white on transparent. |
androidSmallIcon | ic_notification | Drawable name for the PNG above. |
Still yours: the extension's App ID and profile (com.acme.app.NotificationService) — or let EAS manage it via extra.eas.build.experimental.ios.appExtensions — and the .p8 upload (iOS platform setup). The plugin patches the Swift AppDelegate of Expo SDK 53+, and it does not coexist with expo-notifications, which claims the same notification delegate.
Manual setup (bare React Native)
Android
1 google-services.json in android/app/ and the Gradle plugin — Android platform setup. Firebase Messaging itself arrives through the package's Gradle module; add nothing to dependencies.
2 A monochrome status-bar icon at android/app/src/main/res/drawable/ic_notification.png (white on transparent), passed as androidSmallIcon. Android draws anything else as a grey square.
3 Android 13+: requestPermission() asks for POST_NOTIFICATIONS; the module's manifest declares it.
Autolinking picks the module up on the next build. Already have a FirebaseMessagingService? Forward onNewToken and onMessageReceived exactly as the Kotlin SDK documents — it is the same code.
iOS
1 cd ios && pod install.
2 Xcode → app target → Signing & Capabilities → add Push Notifications and Background Modes → Remote notifications.
3 Forward four callbacks from AppDelegate. Swift:
import OpenNotification
import UserNotifications
// in application(_:didFinishLaunchingWithOptions:)
UNUserNotificationCenter.current().delegate = self
OpenNotification.setLaunchNotification(launchOptions?[.remoteNotification] as? [AnyHashable: Any])
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
OpenNotification.didRegister(deviceToken: deviceToken)
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
OpenNotification.didFailToRegister(error: error)
}
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
OpenNotification.didReceive(response: response)
completionHandler()
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
OpenNotification.willPresent(notification: notification)
completionHandler([.banner, .sound, .badge])
}
}
Objective-C AppDelegate.mm: #import "YourApp-Swift.h" and call the same four OpenNotification class methods.
Without these, permission is granted, no token ever arrives, and requestPermission() resolves with no_token.
4 Notification Service Extension — required for delivered, images and buttons on iOS:
- File → New → Target… → Notification Service Extension, name it
NotificationService, deployment target = app's. - Replace its
NotificationService.swiftwithnode_modules/@opennotification/react-native/ios/NotificationServiceExtension/NotificationService.swift. - In the extension's
Info.plistadd StringOpenNotificationApiUrl= your API base URL. - Give it its own App ID / profile (
com.acme.app.NotificationService).
5 Upload the .p8 — iOS platform setup.
What reports what
| Event | iOS | Android |
|---|---|---|
delivered | Notification Service Extension | FirebaseMessagingService (native) |
opened | didReceive response → native module | NotificationReceiver (native) |
clicked (button) | didReceive with an action identifier | NotificationReceiver with the button id |
dismissed | not available on iOS | NotificationReceiver (native) |
On Android every ping is sent natively — FCM may wake the process with no JavaScript in it — and the JavaScript client only relays events to your listeners. On iOS the client reports opens itself.
Notes
- Messages are normalised: APNs
onblock and FCM flatdataboth arrive as oneOpenNotificationMessage. - Only types are imported from
@opennotification/core— nothing from the server ends up in your bundle. - The subscription id lives in UserDefaults / SharedPreferences. Pass your own
storagetocreateClientif the app would rather own persistence; without any native module (tests) it stays in memory and the device re-registers next launch, which upserts the same row by token.