Skip to main content

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:

PlatformWhat the plugin does
iOSaps-environment entitlement, remote-notification background mode, the four AppDelegate callbacks, a NotificationService extension target with OpenNotificationApiUrl = endpoint in its Info.plist.
AndroidPOST_NOTIFICATIONS permission, copies androidSmallIconPath to res/drawable/ic_notification.png. google-services.json and the Gradle plugin come from Expo's own android.googleServicesFile.
OptionDefault
endpointAPI base URL for the extension.
iosServiceExtensiontrueSet false to skip the extension (no delivered on iOS then).
iosServiceExtensionNameNotificationServiceTarget name; bundle id is <bundleIdentifier>.<name>.
iosDeploymentTargetapp's
iosApsEnvironmentdevelopmentEAS sets the right one at build time.
androidSmallIconPathMonochrome PNG, white on transparent.
androidSmallIconic_notificationDrawable 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:

  1. File → New → Target… → Notification Service Extension, name it NotificationService, deployment target = app's.
  2. Replace its NotificationService.swift with node_modules/@opennotification/react-native/ios/NotificationServiceExtension/NotificationService.swift.
  3. In the extension's Info.plist add String OpenNotificationApiUrl = your API base URL.
  4. Give it its own App ID / profile (com.acme.app.NotificationService).

5 Upload the .p8iOS platform setup.

What reports what

EventiOSAndroid
deliveredNotification Service ExtensionFirebaseMessagingService (native)
openeddidReceive response → native moduleNotificationReceiver (native)
clicked (button)didReceive with an action identifierNotificationReceiver with the button id
dismissednot available on iOSNotificationReceiver (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 on block and FCM flat data both arrive as one OpenNotificationMessage.
  • 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 storage to createClient if 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.