# Notify Kit FCM Mode > **Hitting upstream Notifee [#1133](https://github.com/invertase/notifee/issues/1133) ("onBackgroundEvent not triggered on iOS for remote notifications")?** FCM Mode resolves it. The client-side `onBackgroundEvent` handler only fires for remote pushes on iOS when the APNs payload carries a `notifee_options` blob that the Notification Service Extension can enrich into `userInfo[__notifee_notification]` before delivery. Shipping FCM payloads via the server SDK and wiring the NSE through the Expo config plugin or the bare React Native `init-nse` CLI is the end-to-end fix. Expo Go is not supported. A delivery pattern for apps that want **`react-native-notify-kit` as the sole display layer for FCM push notifications** on both Android and iOS. FCM Mode solves two problems at once: the Android `notification`-payload duplicate (the system tray draws the push, and then your client draws it again via `displayNotification`) and the iOS data-only payload drop rate (APNs throttles silent pushes aggressively — ~30–60% loss is typical on real devices). It uses a **different FCM payload shape per platform** but the same developer API, so the asymmetry is invisible to your app code. - [Overview](#overview) - [Architecture](#architecture) - [Quick start](#quick-start) - [Expo CNG / development builds](#expo-cng--development-builds) - [Server SDK reference](#server-sdk-reference) - [Client API reference](#client-api-reference) - [iOS NSE setup](#ios-nse-setup) - [Android specifics](#android-specifics) - [Payload reference](#payload-reference) - [Migration from the manual pattern](#migration-from-the-manual-pattern) - [Troubleshooting](#troubleshooting) - [Known limitations](#known-limitations) - [Comparison with other libraries](#comparison-with-other-libraries) ## Overview ### The problem FCM has two delivery modes, and neither is a good default for apps that use Notify Kit for display: | Payload shape | Android behavior | iOS behavior | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `notification` (alert) | OS auto-displays via the FCM SDK. If you also call `displayNotification` in `setBackgroundMessageHandler`, you get **two tray entries**. Custom `data` is routed to the tap `PendingIntent` only, so `getDisplayedNotifications()` can't see it (tracked at [firebase-android-sdk#2639](https://github.com/firebase/firebase-android-sdk/issues/2639)). | APNs delivers reliably. If you want Notify Kit to rewrite the notification (attachments, categories, custom sound), you need a Notification Service Extension. | | `data` only | OS wakes your app and you call `displayNotification` yourself — clean, but iOS treats these as silent pushes and throttles / drops them aggressively. Real-device loss rates of **30–60%** are typical; worse under Low Power Mode and with the app force-quit. | Same delivery throttling as above. Unusable for user-facing pushes. | ### The solution FCM Mode picks the right payload per platform and hides the asymmetry behind a single developer API: - **Android** uses a **data-only** payload, so the FCM SDK never auto-displays. Your app receives the message in `setBackgroundMessageHandler` / `onMessage` and calls `notifee.handleFcmMessage(remoteMessage)` — a one-liner that parses the embedded `notifee_options` blob and renders the notification with full control over channel, style, actions, etc. - **iOS** uses an **alert payload** (`aps.alert`) with `mutable-content: 1` and a `notifee_options` blob in `apns.payload`. A Notification Service Extension reads the blob, reshapes the notification (attachments, category, thread-id, interruption level, sound), and the OS displays the rewritten content. APNs reliability (~99% on APNs priority 10) instead of the silent-push throttle. - The **server SDK** (`react-native-notify-kit/server`) generates both payload shapes from one `buildNotifyKitPayload(input)` call, so your backend doesn't care about the split. - The **Expo config plugin** generates and wires the iOS NSE during Expo prebuild and can configure Android foreground service manifest requirements when explicitly opted in. For bare React Native, the **CLI** (`npx react-native-notify-kit init-nse`) scaffolds the same NSE target, patches the Podfile, and wires the `.pbxproj`. If you're already using the "data-only + `displayNotification` from headless task" pattern documented in the main README, FCM Mode is a superset: it replaces the iOS half with an alert + NSE path that doesn't drop pushes. See the [migration guide](#migration-from-the-manual-pattern) below. ### When to use FCM Mode Use it when: - You send push notifications from a Node.js backend (or Firebase Cloud Functions) and want one library to own display on both platforms. - You need consistent behavior across platforms (styles, channels, actions, attachments, tap handling) without maintaining platform branches server-side. - You want iOS APNs delivery reliability without giving up client-side control over presentation. Stick with a simpler pattern when: - You only ship Android, or only ship iOS — the asymmetry cost disappears. - You're happy with iOS data-only drops — many marketing / engagement notifications are fine at 60% delivery. - You need Expo Go. FCM Mode and foreground service support require native modules or native targets, so use Expo CNG / prebuild development builds instead. ## Architecture ```text ┌──────────────────┐ │ Your backend │ │ (Node.js / CFns) │ └────────┬─────────┘ │ buildNotifyKitPayload({ token, notification, options }) ▼ ┌──────────────────────────────────────┐ │ react-native-notify-kit/server │ │ - Android: data-only payload │ │ - iOS: aps.alert + mutable=1 │ │ - notifee_options blob (identical) │ └────────┬─────────────────────────────┘ │ admin.messaging().send(message) ▼ ┌──────────────────┐ ┌──────────────────┐ │ FCM service │ ──► Android ──► │ Device (app) │ │ (HTTP v1) │ │ setBackgroundMsg │ │ │ │ Handler → ... │ │ │ │ handleFcmMessage │ │ │ │ → displayNotif. │ │ │ └──────────────────┘ │ │ │ │ ──► iOS (APNs) ► ┌──────────────────┐ │ │ │ NSE (NotifyKit- │ │ │ │ NSE.appex) │ │ │ │ - reads blob │ │ │ │ - attachments │ │ │ │ - OS draws it │ │ │ └──────────────────┘ └──────────────────┘ ``` The `notifee_options` blob is byte-identical on both platforms — on Android it rides in `data.notifee_options`, on iOS in `apns.payload.notifee_options`. Title and body are duplicated into `aps.alert` on iOS so the OS can display the initial banner before the NSE finishes. Each platform ignores the fields the other platform needs. ## Quick start ### 1. Install ```bash yarn add react-native-notify-kit @react-native-firebase/app @react-native-firebase/messaging ``` The CLI and the server SDK ship with the main package — no extra installs. ### 2. Server: build and send the payload ```ts // server/sendNotification.ts import { buildNotifyKitPayload } from 'react-native-notify-kit/server'; import * as admin from 'firebase-admin'; admin.initializeApp(); export async function sendOrderUpdate(deviceToken: string, orderId: string) { const message = buildNotifyKitPayload({ token: deviceToken, notification: { id: `order-${orderId}`, title: 'Your order is on the way', body: 'Tap to see live tracking.', data: { orderId, screen: 'tracking' }, android: { channelId: 'orders', smallIcon: 'ic_notification', color: '#4CAF50', pressAction: { id: 'open-order', launchActivity: 'default' }, }, ios: { sound: 'default', categoryId: 'ORDER_UPDATE', interruptionLevel: 'timeSensitive', attachments: [{ url: 'https://cdn.example.com/orders/42.png' }], }, }, options: { androidPriority: 'high', iosBadgeCount: 1, ttl: 3600, }, }); await admin.messaging().send(message); } ``` ### 3. Android client: wire `handleFcmMessage` In your app's `index.js` (before `AppRegistry.registerComponent`): ```ts // index.js import { AppRegistry } from 'react-native'; import messaging from '@react-native-firebase/messaging'; import notifee, { AndroidImportance } from 'react-native-notify-kit'; import App from './App'; // Optional: configure defaults once at startup notifee.setFcmConfig({ defaultChannelId: 'default', defaultPressAction: { id: 'default', launchActivity: 'default' }, }); // Create the channel your payloads reference notifee.createChannel({ id: 'orders', name: 'Orders', importance: AndroidImportance.HIGH }); notifee.createChannel({ id: 'default', name: 'Default', importance: AndroidImportance.HIGH }); // Background + killed state messaging().setBackgroundMessageHandler(async remoteMessage => { await notifee.handleFcmMessage(remoteMessage); }); AppRegistry.registerComponent('MyApp', () => App); ``` And in a component for foreground delivery: ```tsx // App.tsx import { useEffect } from 'react'; import messaging from '@react-native-firebase/messaging'; import notifee from 'react-native-notify-kit'; export default function App() { useEffect(() => { const unsubscribe = messaging().onMessage(async remoteMessage => { await notifee.handleFcmMessage(remoteMessage); }); return unsubscribe; }, []); // ... your UI } ``` That's it for Android. On iOS the same `handleFcmMessage` call is a no-op in background/killed (the NSE has already displayed the notification); in foreground it displays the in-app banner as usual. ### 4. iOS client: create the NSE For Expo CNG / development builds, add the config plugin and run Expo prebuild. Do not run this in Expo Go. ```ts export default { expo: { name: 'MyApp', slug: 'my-app', ios: { bundleIdentifier: 'com.example.myapp', }, plugins: [ [ 'react-native-notify-kit', { ios: { notificationServiceExtension: true, }, }, ], ], }, }; ``` For bare React Native, run: ```bash npx react-native-notify-kit init-nse cd ios && pod install ``` Both paths create a `NotifyKitNSE` target, add the `RNNotifeeCore` dependency, and embed the `.appex` in the host app. Full detail in [iOS NSE setup](#ios-nse-setup). ### 5. Verify Send a test payload to a real device. The notification should: - Display once on both platforms (no duplicates). - Show any attachments, category actions, thread-id grouping, and custom sounds server-side. - Route tap handling through your registered handlers after you configure and validate your app's press-action flow. Keep reading for the full API surface and error cases. ## Expo CNG / development builds Expo CNG / prebuild / development builds are supported. Expo Go is not supported because NotifyKit requires native modules, and iOS FCM Mode requires a native Notification Service Extension target embedded in the app. iOS and Android have different responsibilities: - iOS FCM Mode uses the NotifyKit config plugin to generate and wire the Notification Service Extension during prebuild. - Android FCM Mode remains data-only. RNFirebase receives the message and your app calls `notifee.handleFcmMessage(remoteMessage)`. - Android foreground service manifest configuration is available through the NotifyKit config plugin, but it is explicit opt-in. ### iOS Expo setup Boolean `true` enables the default NSE target during Expo prebuild: ```ts export default { expo: { name: 'MyApp', slug: 'my-app', ios: { bundleIdentifier: 'com.example.myapp', googleServicesFile: './GoogleService-Info.plist', }, plugins: [ [ 'react-native-notify-kit', { ios: { notificationServiceExtension: true, }, }, ], ], }, }; ``` Use the object form only when you need a custom target name or bundle suffix: ```ts export default { expo: { name: 'MyApp', slug: 'my-app', ios: { bundleIdentifier: 'com.example.myapp', }, plugins: [ [ 'react-native-notify-kit', { ios: { notificationServiceExtension: { enabled: true, targetName: 'NotifyKitNSE', bundleSuffix: '.NotifyKitNSE', }, }, }, ], ], }, }; ``` The object form is explicit: set `enabled: true`. An object without `enabled: true` is treated as disabled. ### What the iOS plugin changes During Expo prebuild, the plugin: - upserts `extra.eas.build.experimental.ios.appExtensions` with the NSE target and bundle identifier - generates `NotificationService.swift` - generates the NSE `Info.plist` - generates an entitlements file - patches the Xcode project with the `NotifyKitNSE` target - adds the host app dependency on the NSE target - embeds `NotifyKitNSE.appex` in the host app's Copy Files phase - patches the Podfile with a top-level `NotifyKitNSE` target and `pod 'RNNotifeeCore'` - mirrors static `use_frameworks!` linkage when Expo/Firebase static frameworks require it The default bundle identifier is `.NotifyKitNSE`. The plugin requires `ios.bundleIdentifier` when `ios.notificationServiceExtension` is enabled. ### iOS prerequisites - Use Expo prebuild, `expo run:ios`, or EAS development builds. Expo Go cannot load the generated native extension. - Add `GoogleService-Info.plist` to the Expo project and point `ios.googleServicesFile` at it if your Firebase setup expects Expo to copy it. - Configure APNs key or certificates in Firebase Console. - Configure Apple signing, capabilities, and any EAS credentials needed by your project and the generated app extension. - Test remote pushes on a physical iPhone. The simulator is not a reliable gate for remote-push NSE behavior. Typical local flow: ```bash npx expo prebuild --platform ios npx expo run:ios --device ``` For EAS: ```bash eas build --profile development --platform ios ``` ### iOS work that remains manual - Firebase project setup - APNs setup in Firebase - Apple signing and capability review when your project uses custom provisioning or App Groups - EAS credentials if your build profile does not auto-manage them - Any custom NSE logic you add after generation ### Android Expo setup The NotifyKit plugin does not configure Firebase or RNFirebase on Android. Configure the Expo app as a normal RNFirebase development build: - Register a Firebase Android app whose package matches `expo.android.package`, for example `com.example.yourapp`. - Place `google-services.json` in your Expo project. - Point `android.googleServicesFile` at that file. - Add the RNFirebase Expo plugins required by your project, typically `@react-native-firebase/app` and `@react-native-firebase/messaging`. - Build a development build with Expo prebuild, `expo run:android`, or EAS. Minimal Expo config shape: ```ts export default { expo: { name: 'MyApp', slug: 'my-app', android: { package: 'com.example.yourapp', googleServicesFile: './google-services.json', }, plugins: [ '@react-native-firebase/app', '@react-native-firebase/messaging', 'react-native-notify-kit', ], }, }; ``` Android FCM Mode uses data-only messages. Do not use an Android `notification` payload for NotifyKit FCM Mode, because the FCM SDK can auto-display it before NotifyKit handles it. Create the Android channel used by your payloads before displaying notifications, configure a fallback channel if needed, and wire both RNFirebase receive paths. `defaultChannelId` must point to an existing Android channel. ```ts import messaging from '@react-native-firebase/messaging'; import notifee, { AndroidImportance } from 'react-native-notify-kit'; await notifee.createChannel({ id: 'default', name: 'Default', importance: AndroidImportance.HIGH, }); await notifee.setFcmConfig({ defaultChannelId: 'default', defaultPressAction: { id: 'default', launchActivity: 'default' }, }); messaging().setBackgroundMessageHandler(async remoteMessage => { await notifee.handleFcmMessage(remoteMessage); }); messaging().onMessage(async remoteMessage => { await notifee.handleFcmMessage(remoteMessage); }); ``` For background data-only validation, send high-priority Android FCM messages, for example `options.androidPriority: 'high'` when using `buildNotifyKitPayload`. Delivery still depends on FCM priority, device state, Doze, and OEM policy. ### Android foreground service config plugin Android foreground service manifest configuration is opt-in through the NotifyKit config plugin. If `android.foregroundService` is omitted, the Android manifest is unchanged and no foreground service type is chosen for you. Single type: ```ts export default { expo: { plugins: [ [ 'react-native-notify-kit', { android: { foregroundService: { types: ['shortService'], }, }, }, ], ], }, }; ``` Multiple types: ```ts export default { expo: { plugins: [ [ 'react-native-notify-kit', { android: { foregroundService: { types: ['dataSync', 'remoteMessaging'], }, }, }, ], ], }, }; ``` `specialUse` requires an explicit Play-policy subtype: ```ts export default { expo: { plugins: [ [ 'react-native-notify-kit', { android: { foregroundService: { types: ['specialUse'], specialUseSubtype: 'Explain the user-visible special foreground service use case', }, }, }, ], ], }, }; ``` When enabled, the plugin writes `app.notifee.core.ForegroundService`, `android:foregroundServiceType`, the base `FOREGROUND_SERVICE` permission, type-specific `FOREGROUND_SERVICE_*` permissions where Android defines them, and `android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE` when `specialUse` is used. For 10.4.0, the Android Expo foreground service config plugin was validated with a real prebuild and a runtime `shortService` smoke on a Pixel 9 Pro XL. The runtime gate covered `displayNotification` with `asForegroundService`, `registerForegroundService`, `stopForegroundService`, `cancelNotification`, and `dumpsys` confirmation of `types=0x00000800` / `isShortFgs=true`. The plugin does not add `USE_EXACT_ALARM`, does not add `USE_FULL_SCREEN_INTENT`, does not configure Firebase, and does not add Maven repositories or Gradle workarounds. Runtime permissions such as camera, location, microphone, media projection, and notification permission remain the app's responsibility. Google Play foreground service policy compliance is also the app developer's responsibility. ## Server SDK reference Import from `react-native-notify-kit/server`. Runs in Node.js 22+ and Firebase Cloud Functions. Zero runtime dependencies. ### `buildNotifyKitPayload(input): NotifyKitPayloadOutput` Builds a complete FCM HTTP v1 `Message` object ready for `admin.messaging().send()`. Validates input, serializes the `notifee_options` blob, and emits both the Android data-only half and the iOS APNs alert half. ```ts const message = buildNotifyKitPayload({ token: 'eZ...device token', notification: { id, title, body, data?, android?, ios? }, options: { androidPriority?, iosBadgeCount?, ttl?, collapseKey? }, }); ``` Input type: ```ts type NotifyKitPayloadInput = { // Exactly one of these three: token?: string; // single device topic?: string; // FCM topic (e.g. 'news', 'sports') condition?: string; // FCM condition expression notification: NotifyKitNotification; options?: NotifyKitOptions; }; type NotifyKitNotification = { id?: string; // also used as collapse key unless options.collapseKey is set title: string; // required, non-empty body: string; // required, non-empty data?: Record; android?: NotifyKitAndroidConfig; ios?: NotifyKitIosConfig; }; type NotifyKitAndroidConfig = { channelId?: string; smallIcon?: string; largeIcon?: string; color?: string; pressAction?: { id: string; launchActivity?: string }; actions?: Array<{ title: string; pressAction: { id; launchActivity? }; input?: boolean }>; style?: { type: 'BIG_TEXT'; text: string } | { type: 'BIG_PICTURE'; picture: string }; }; type NotifyKitIosConfig = { sound?: string; categoryId?: string; threadId?: string; interruptionLevel?: 'passive' | 'active' | 'timeSensitive' | 'critical'; attachments?: Array<{ url: string; identifier?: string }>; }; type NotifyKitOptions = { androidPriority?: 'high' | 'normal'; iosBadgeCount?: number; // non-negative integer ttl?: number; // seconds, positive integer collapseKey?: string; }; ``` The returned value is a valid FCM `Message`: ```ts type NotifyKitPayloadOutput = { token?: string; topic?: string; condition?: string; data: Record; // your data keys + notifee_options (+ notifee_data when > 5 keys) android: { priority: 'HIGH' | 'NORMAL'; collapse_key?: string; ttl?: string; // '3600s' format }; apns: { headers: { 'apns-push-type': 'alert'; 'apns-priority': '10'; 'apns-collapse-id'?: string; 'apns-expiration'?: string; }; payload: { aps: { alert: { title; body }; 'mutable-content': 1; sound?; category?; 'thread-id'?; 'interruption-level'?; badge?; }; notifee_options: string; // JSON blob, see Payload reference notifee_data?: string; }; }; sizeBytes: number; // non-enumerable — not serialized with JSON.stringify }; ``` `sizeBytes` is defined as **non-enumerable**: it's accessible on the returned object for your own diagnostics, but `JSON.stringify(message)` strips it, so it never leaks onto the wire. ### Other exports ```ts import { buildNotifyKitPayload, // main entry buildAndroidPayload, // android half only — for custom merging buildIosApnsPayload, // iOS half only — for custom merging serializeNotifeeOptions, // JSON-serialize the blob directly } from 'react-native-notify-kit/server'; import type { NotifyKitPayloadInput, NotifyKitPayloadOutput, NotifyKitNotification, NotifyKitOptions, NotifyKitAndroidConfig, NotifyKitIosConfig, NotifyKitPressAction, NotifyKitAndroidAction, NotifyKitAndroidStyle, NotifyKitIosAttachment, NotifyKitIosInterruptionLevel, // raw FCM output shapes NotifyKitAndroidOutput, NotifyKitApnsOutput, NotifyKitApnsAps, NotifyKitApnsHeaders, NotifyKitApnsPayload, SerializedNotifeeOptions, ApnsInterruptionLevel, } from 'react-native-notify-kit/server'; ``` ### Validation rules Every error is thrown synchronously from `buildNotifyKitPayload`. Error messages are prefixed with `[react-native-notify-kit/server]` so they're grep-able in Cloud Functions logs. | Rule | Error message | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Input must be an object | `Validation: input must be an object` | | Exactly one of `token` / `topic` / `condition` | `Routing: exactly one of 'token', 'topic', or 'condition' must be provided. Got: ` | | `token` non-empty string | `Routing: 'token' must be a non-empty string` | | `topic` non-empty string | `Routing: 'topic' must be a non-empty string` | | `condition` non-empty string | `Routing: 'condition' must be a non-empty string` | | `notification` required | `Validation: 'notification' is required and must be an object` | | `notification.id` non-empty when provided | `Validation: notification.id must be a non-empty string when provided` | | `notification.title` required | `Validation: notification.title is required and must be a non-empty string` | | `notification.body` required | `Validation: notification.body is required and must be a non-empty string` | | `notification.data` must be an object | `Validation: 'notification.data' must be an object` | | `data` values must be strings | `Validation: FCM data values must be strings. Got for key ''. Use JSON.stringify() if you need to pass complex values.` | | Reserved keys rejected | `Validation: 'notifee_options' and 'notifee_data' are reserved keys and cannot be used in notification.data` | | iOS attachments array | `iOS: 'notification.ios.attachments' must be an array` | | iOS attachment shape | `iOS: each attachment must be an object with a string 'url' field` | | iOS attachment https-only | `iOS: iOS attachments require https:// URLs. Got: ` | | iOS interruptionLevel enum | `Validation: invalid interruptionLevel ''. Expected one of: passive, active, timeSensitive, critical` | | `options` must be an object | `Validation: 'options' must be an object` | | `options.androidPriority` enum | `Validation: 'options.androidPriority' must be 'high' or 'normal'. Got: ` | | `options.iosBadgeCount` non-negative int | `Validation: 'options.iosBadgeCount' must be a non-negative integer` | | `options.ttl` positive int | `Validation: options.ttl must be a positive integer (seconds). Got: ` | | `options.collapseKey` non-empty | `Validation: 'options.collapseKey' must be a non-empty string` | | Blob must be serializable | `Serialization: notifee_options contains circular references or non-serializable values` | > **Note on `ttl: 0`.** Zero is rejected because it's semantically ambiguous ("never expire" vs "expire immediately") and FCM's HTTP v1 API uses the same string format for both concepts. Omit `ttl` entirely to use FCM's default (4 weeks), or pass a positive integer in seconds. > **Note on `firebase-admin` TTL compatibility.** `buildNotifyKitPayload` emits `android.ttl` in FCM HTTP v1 wire format (`"3600s"`), which is what the FCM REST API expects. `firebase-admin`'s `admin.messaging().send()` validates input in the SDK layer before serializing and expects `ttl` as a **number of milliseconds** (`3_600_000`). If you route through `firebase-admin` and pass `options.ttl`, normalize it before sending: > > ```ts > const message = buildNotifyKitPayload(input); > if (typeof message.android.ttl === 'string') { > const match = message.android.ttl.match(/^(\d+)s$/); > if (match) (message.android as any).ttl = Number(match[1]) * 1000; > } > await admin.messaging().send(message); > ``` > > See [`scripts/send-test-fcm.js`](../scripts/send-test-fcm.js) in this repo for the reference adapter. ### Payload size FCM has a **4 KB hard limit** per message (the HTTP v1 `Message` JSON, not just your `data` map). The server SDK emits a `console.warn` when the serialized payload exceeds ~3500 bytes — enough headroom for FCM's own wrapping. Size is measured with `Buffer.byteLength(json, 'utf8')`, so emoji and CJK characters are counted correctly. ```text [react-native-notify-kit/server] Payload size 3612 bytes approaches FCM 4KB limit. Consider reducing notifee_options. ``` Read `output.sizeBytes` for programmatic checks: ```ts const message = buildNotifyKitPayload(input); if (message.sizeBytes > 3500) { // fall back to a smaller payload, split across two messages, or switch to a // backend fetch (push a small "something new" nudge and fetch the full // content from your API when the app opens) } ``` ### Reserved keys These keys are **rejected** in `notification.data` (the SDK throws): - `notifee_options` - `notifee_data` These keys are **preserved** by the SDK but have special meaning on the client: - Anything matching FCM's own denylist (`from`, `collapse_key`, `message_type`, `message_id`, `aps`, `fcm_options`, and prefix filters `google.`, `gcm.`, `fcm.`, `android.`, `notifee`) — the FCM SDK strips these before delivery anyway. ### Firebase Cloud Functions example ```ts // functions/src/index.ts import { onDocumentCreated } from 'firebase-functions/v2/firestore'; import { buildNotifyKitPayload } from 'react-native-notify-kit/server'; import * as admin from 'firebase-admin'; admin.initializeApp(); export const onOrderCreated = onDocumentCreated('orders/{orderId}', async event => { const order = event.data?.data(); if (!order?.deviceToken) return; const message = buildNotifyKitPayload({ token: order.deviceToken, notification: { id: `order-${event.params.orderId}`, title: 'Order received', body: `We're preparing ${order.itemName}.`, data: { orderId: event.params.orderId }, android: { channelId: 'orders' }, ios: { sound: 'default', interruptionLevel: 'timeSensitive' }, }, options: { androidPriority: 'high', ttl: 3600 }, }); await admin.messaging().send(message); }); ``` ## Client API reference Import the default `notifee` module — `handleFcmMessage` and `setFcmConfig` are instance methods on the singleton. ### `notifee.handleFcmMessage(remoteMessage): Promise` Processes an FCM remote message produced by the server SDK and displays a Notify Kit notification according to the embedded `notifee_options`. Safe to call from both `setBackgroundMessageHandler` and `onMessage`. **Returns:** the displayed notification ID, or `null` if the call was an intentional no-op. **Behavior matrix:** | Platform | App state | Payload | Behavior | | -------- | ---------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Android | foreground | with `notifee_options` | `displayNotification(...)` — notification appears | | Android | background | with `notifee_options` | `displayNotification(...)` — notification appears | | Android | killed | with `notifee_options` | best-effort: when FCM wakes the app, the headless task runs and calls `displayNotification(...)` | | Android | any | no `notifee_options` and `fallbackBehavior: 'display'` (default) | minimal notification built from `remoteMessage.notification` / `remoteMessage.data.title` / `remoteMessage.data.body` | | Android | any | no `notifee_options` and `fallbackBehavior: 'ignore'` | returns `null`, no display | | iOS | foreground | with `notifee_options` | `displayNotification(...)` — in-app banner (skipped if `suppressForegroundBanner`) | | iOS | background | with `notifee_options` | returns `null` — NSE already displayed | | iOS | killed | with `notifee_options` | returns `null` — NSE already displayed | The iOS background/killed no-op is deliberate: the Notification Service Extension has already drawn the final notification using the same `notifee_options` blob, and a second `displayNotification` call would duplicate it. **Input type:** ```ts type FcmRemoteMessage = { messageId?: string; data?: Record; notification?: { title?: string; body?: string }; }; ``` This is a **structural** type — `handleFcmMessage` doesn't import `@react-native-firebase/messaging`, so you can pass a `RemoteMessage` from that library directly, or any compatible shape if you use a different push SDK. **Thrown errors:** - `notifee.handleFcmMessage(*) 'remoteMessage' expected an object.` — invalid argument. **Console warnings** (non-fatal, listed so you can grep logs): - `[react-native-notify-kit] Failed to parse notifee_options: . Falling back to raw title/body.` - `[react-native-notify-kit] notifee_options parsed to a non-object value. Falling back to raw title/body.` - `[react-native-notify-kit] notifee_options version is newer than supported version 1. Display may be incomplete.` - `[react-native-notify-kit] android.style.type '' present but required '' field missing or not a string. Style ignored.` - `[react-native-notify-kit] Unknown android.style.type ''. Style ignored.` - `[react-native-notify-kit] Unknown ios.interruptionLevel ''. Ignored.` - `[react-native-notify-kit] ios.attachments entry has missing or empty url. Skipped.` - `[react-native-notify-kit] Failed to parse notifee_data. Using top-level data keys only.` - `[react-native-notify-kit] handleFcmMessage: displaying notification with empty title and body. Check your FCM payload.` - `[react-native-notify-kit] handleFcmMessage: Android fallback path has no channelId (no payload channelId, no defaultChannelId configured). Notification may be dropped by the OS.` ### `notifee.setFcmConfig(config): Promise` Sets defaults that `handleFcmMessage` consults when the payload leaves a field unset. Call once at app startup, typically in `index.js` before `AppRegistry.registerComponent`. Resolves synchronously; the Promise return is there so a future release can persist config across cold starts without a breaking API change. **Config type:** ```ts type FcmConfig = { /** Used when notifee_options.android.channelId is absent. */ defaultChannelId?: string; /** Used when notifee_options.android.pressAction is absent. */ defaultPressAction?: { id: string; launchActivity?: string }; /** * What to do when remoteMessage.data.notifee_options is entirely missing. * - 'display': build a minimal notification from remoteMessage.notification. * - 'ignore': return null. * @default 'display' */ fallbackBehavior?: 'display' | 'ignore'; ios?: { /** When true, foreground notifications from handleFcmMessage are not displayed. */ suppressForegroundBanner?: boolean; }; }; ``` **Throws** `notifee.setFcmConfig(*) config must be a plain object. Got: ` when called with `null`, an array, or a non-object value. The nested `ios` sub-object is deep-copied on both `setFcmConfig` and every `handleFcmMessage` entry, so mutating the config you passed in doesn't leak through to subsequent calls. ### Example: full startup wiring ```ts // index.js import { AppRegistry } from 'react-native'; import messaging from '@react-native-firebase/messaging'; import notifee, { AndroidImportance } from 'react-native-notify-kit'; import App from './App'; async function bootstrap() { await notifee.requestPermission(); await notifee.createChannel({ id: 'orders', name: 'Orders', importance: AndroidImportance.HIGH, }); await notifee.createChannel({ id: 'default', name: 'Default', importance: AndroidImportance.DEFAULT, }); await notifee.setFcmConfig({ defaultChannelId: 'default', defaultPressAction: { id: 'default', launchActivity: 'default' }, fallbackBehavior: 'display', ios: { suppressForegroundBanner: false }, }); } void bootstrap(); messaging().setBackgroundMessageHandler(async remoteMessage => { await notifee.handleFcmMessage(remoteMessage); }); AppRegistry.registerComponent('MyApp', () => App); ``` ## iOS NSE setup iOS requires a Notification Service Extension (NSE) to rewrite incoming APNs notifications before display. FCM Mode has two automated setup paths: - Expo CNG / prebuild: use the config plugin. - Bare React Native: use the `init-nse` CLI. ### Expo CNG setup Use the [Expo CNG / development builds](#expo-cng--development-builds) setup above. The plugin runs during prebuild and generates the Swift service, `Info.plist`, entitlements, Xcode target, host dependency, `.appex` embed phase, EAS `appExtensions` entry, and Podfile target. Do not use `npx react-native-notify-kit init-nse` as the primary Expo setup path. Generated Expo native folders are disposable, so the plugin is the durable source of truth. ### Bare React Native CLI setup ```bash npx react-native-notify-kit init-nse cd ios && pod install ``` What the bare CLI does: 1. **Auto-detects** your iOS project (`ios/*.xcodeproj` or `.xcworkspace`) and your main app target's bundle ID. 2. **Creates** three files under `ios/NotifyKitNSE/`: - `NotificationService.swift` — calls `NotifeeExtensionHelper.populateNotificationContent(...)` to apply `notifee_options`. - `Info.plist` — sets `NSExtensionPointIdentifier = com.apple.usernotifications.service` and the principal class. - `NotifyKitNSE.entitlements` — empty file (extend if you need App Groups for cross-target data sharing). 3. **Patches** `ios/Podfile` — adds a `target 'NotifyKitNSE'` block nested inside your app target with `inherit! :search_paths`. The `RNNotifeeCore` pod is added as a dependency of the NSE target only (not the main app) to avoid the duplicate-symbols linker error documented in [9.1.22](../CHANGELOG.md). 4. **Patches** `ios/YourApp.xcodeproj/project.pbxproj` — adds the NSE native target with build phases, inherits signing from the parent target, and sets `PRODUCT_BUNDLE_IDENTIFIER = .NotifyKitNSE`. 5. **Backs up** the Podfile and `.pbxproj` before every edit (PID-stamped backups, atomic writes, rollback on failure). Open Xcode after `pod install`, verify the `NotifyKitNSE` target's signing (it should inherit from your app target), build, and you're done. ### CLI reference ```bash npx react-native-notify-kit init-nse [options] ``` | Option | Default | Description | | ----------------------- | --------------- | --------------------------------------------------------------------------------------------------------- | | `--ios-path ` | auto-detect | Path to your iOS directory (e.g. `ios/`). | | `--target-name ` | `NotifyKitNSE` | NSE target name. Must match `/^[A-Za-z0-9_\-.]+$/`. | | `--bundle-suffix ` | `.NotifyKitNSE` | Suffix appended to the parent bundle ID. Must match `/^\.[A-Za-z0-9\-.]+$/` (starts with `.`). | | `-f, --force` | `false` | Overwrite an existing NSE target. Without this flag, the CLI fails fast if `NotifyKitNSE` already exists. | | `-n, --dry-run` | `false` | Print the actions that would be taken, without writing. | **Validation errors** (exact text): - `Invalid target name ''. Must match [A-Za-z0-9_-.]\n Target names can only contain letters, digits, underscores, hyphens, and dots.` — reject target names with special chars. - `Invalid bundle suffix ''. Must start with '.' and contain only letters, digits, hyphens, and dots.` - `NSE target '' already exists in .\n Use --force to overwrite or --target-name to use a different name.` **Parent bundle ID with variables.** If your main app target sets `PRODUCT_BUNDLE_IDENTIFIER` via an Xcode build variable (e.g. `$(PRODUCT_BUNDLE_PREFIX).$(PRODUCT_NAME)`), the CLI logs a warning and writes the literal variable into the NSE bundle ID — you'll need to set the NSE's bundle ID manually in Xcode. This shows up as: ```text Parent bundle ID uses a variable: $(PRODUCT_BUNDLE_PREFIX).MyApp The NSE bundle ID will need to be set manually in Xcode. ``` ### Manual setup If automation does not work for your bare React Native project because of heavily customized Xcode configs or exotic monorepo layouts, the [legacy manual guide](../apps/smoke/NOTIFICATION_SERVICE_EXTENSION.md) walks through the Xcode steps. You'll still use the same `NotifeeExtensionHelper.populateNotificationContent(...)` call; only the scaffolding differs. ### What the Swift template does The generated `NotificationService.swift` is under your ownership after generation. Regenerating with CLI `--force` or rebuilding disposable Expo native folders can overwrite it, so if you customize it, keep the `populateNotificationContent` call intact. The template: 1. Implements `didReceive(_:withContentHandler:)`. 2. Hands the request to `NotifeeExtensionHelper.populateNotificationContent(...)` — the ObjC helper shipped in `RNNotifeeCore` that reads `notifee_options` and applies attachments, category, thread-id, and sound. 3. Logs `[NotifyKitNSE] didReceive ...` and `[NotifyKitNSE] contentHandler ...` via `NSLog`, viewable in Console.app with filter `subsystem:NotifyKitNSE` when attaching to your NSE target. 4. Implements `serviceExtensionTimeWillExpire` as a safety net — iOS gives the NSE ~30 seconds before killing it; if an attachment download stalls, the NSE delivers whatever it has so far instead of dropping the notification entirely. ### No bridging header needed The NSE is pure Swift. `RNNotifeeCore` exposes `NotifeeExtensionHelper` as an Objective-C class with `NS_SWIFT_NAME` hints, so Swift imports it directly (`import RNNotifeeCore`). No `NotifyKitNSE-Bridging-Header.h` is required. ### Deployment target The NSE target defaults to **iOS 15.1**, matching the main library deployment target. If your main app targets a higher version, update the NSE target in Xcode → Build Settings → Deployment → **iOS Deployment Target**. ### Debugging the NSE Attach to the running NSE process from Xcode: 1. Run the main app on device. 2. In Xcode: **Debug → Attach to Process → `NotifyKitNSE`** (appears after the first push arrives and spawns the extension). 3. Send a push. Set breakpoints in `NotificationService.swift` or log with `NSLog`. You can also read NSE logs in **Console.app** — filter by process `NotifyKitNSE`. The template emits two log lines per normal invocation (entry + completion), plus a third on the timeout path: ```text [NotifyKitNSE] didReceive id=... title=... hasNotifeeOptions=true requestedAttachments=1 urls=https://... [NotifyKitNSE] contentHandler id=... title=... deliveredAttachments=1 identifiers=notifee-attachment-0 [NotifyKitNSE] serviceExtensionTimeWillExpire id=... title=... deliveredAttachments=0 ``` If `hasNotifeeOptions=false`, the server didn't send a NotifyKit-shaped payload — either you're not using `buildNotifyKitPayload`, or the payload was stripped by a proxy. ## Android specifics ### Data-only delivery The server SDK always emits an Android data-only message — there's no `notification` field in the FCM payload. That's what makes `setBackgroundMessageHandler` / `onMessage` fire instead of the FCM SDK auto-displaying. ### Channels are your responsibility `handleFcmMessage` honors whatever `channelId` the server sends, falling back to `defaultChannelId` from `setFcmConfig`. The channel **must exist before the notification is displayed** — create channels at app startup: ```ts await notifee.createChannel({ id: 'orders', name: 'Orders', importance: AndroidImportance.HIGH, sound: 'default', // If you want a custom sound: // sound: 'my_custom_sound', // file at android/app/src/main/res/raw/my_custom_sound.mp3 }); ``` > **Android:** The `NotificationChannel` sound is immutable after creation. To change the sound you must delete and recreate the channel under a new ID. See the [custom sounds note](../README.md#custom-sounds-for-push-notifications-in-background-or-killed-state) in the main README. ### Style mapping Server-side style enums are strings (`'BIG_TEXT'`, `'BIG_PICTURE'`) to survive JSON serialization. On the client, `handleFcmMessage` maps them to `AndroidStyle.BIGTEXT` / `AndroidStyle.BIGPICTURE`: ```ts // Server android: { channelId: 'news', style: { type: 'BIG_PICTURE', picture: 'https://cdn.example.com/banner.png' }, } // Client receives (and calls displayNotification with): android: { channelId: 'news', style: { type: AndroidStyle.BIGPICTURE, picture: 'https://cdn.example.com/banner.png' }, } ``` Other `AndroidStyle` values (`MESSAGING`, `INBOX`, `CALL`) aren't wired through the server SDK yet — they have richer schemas that need future versioning. Build them yourself via `displayNotification` until then. ### Action buttons ```ts android: { actions: [ { title: 'Accept', pressAction: { id: 'accept-order' } }, { title: 'Decline', pressAction: { id: 'decline-order' } }, { title: 'Reply', pressAction: { id: 'reply' }, input: true }, ], } ``` Handle the action `id` in your `onBackgroundEvent` / `onForegroundEvent` listener: ```ts notifee.onBackgroundEvent(async ({ type, detail }) => { if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'accept-order') { // ... } }); ``` ### Foreground delivery When the app is in foreground, Android shows a normal notification in the tray (the library doesn't have the iOS "in-app banner" concept). To suppress foreground display, check `AppState` yourself and branch: ```ts messaging().onMessage(async remoteMessage => { if (AppState.currentState === 'active') { // Route to an in-app toast instead of a tray notification showInAppToast(remoteMessage.notification); return; } await notifee.handleFcmMessage(remoteMessage); }); ``` ## Payload reference ### Full `notifee_options` schema ```jsonc { "_v": 1, "title": "Order received", "body": "We're preparing your food.", "android": { "channelId": "orders", "smallIcon": "ic_notification", "largeIcon": "https://cdn.example.com/avatar.png", "color": "#4CAF50", "pressAction": { "id": "open-order", "launchActivity": "default" }, "actions": [ { "title": "Accept", "pressAction": { "id": "accept" } }, { "title": "Decline", "pressAction": { "id": "decline" }, "input": true }, ], "style": { "type": "BIG_TEXT", "text": "Long body text ..." }, }, "ios": { "sound": "default", "categoryId": "ORDER_UPDATE", "threadId": "orders-thread", "interruptionLevel": "timeSensitive", "attachments": [ { "url": "https://cdn.example.com/orders/42.png", "identifier": "attachment-0" }, ], }, } ``` ### `_v` version field Every blob carries `_v: 1`. When a future client encounters `_v > 1`, it parses what it understands and logs: ```text [react-native-notify-kit] notifee_options version 2 is newer than supported version 1. Display may be incomplete. ``` Bump client `react-native-notify-kit` to pick up new fields — old clients never crash, they just miss the new fields. ### Reserved top-level FCM data keys The FCM SDK strips these keys before your `setBackgroundMessageHandler` / `onMessage` handler sees them: - **Prefixes:** `android.`, `google.`, `gcm.`, `fcm.` - **`notifee`** prefix (without trailing dot — the library's namespace, so `notifeeFoo` is also filtered) - **Exact:** `from`, `collapse_key`, `message_type`, `message_id`, `aps`, `fcm_options` The server SDK additionally rejects `notifee_options` and `notifee_data` in your `notification.data` to prevent collisions with the transport blob. See the [main README](../README.md#bugs-fixed-from-upstream-notifee) for the iOS / Android divergence on bare-`fcm` keys. ### 4 KB FCM limit FCM enforces a hard 4 KB limit on the entire serialized message. The server SDK warns at ~3500 bytes; common causes of going over: - Long `body` text — use an Android `BIG_TEXT` style instead, which doesn't contribute to the size cap if the full text is inline but the banner text is short. - Many `data` keys — collapse nested objects into a single JSON string (`JSON.stringify`) and parse on the client. The reserved keys `notifee_options` / `notifee_data` are off-limits. - Long attachment URLs — shorten via a CDN or URL shortener. If you're close to 4 KB, send a **nudge** payload instead: push just an ID, and have the client fetch the full content from your API when it handles the push. ## Migration from the manual pattern If you currently do this: ```ts // OLD — manual pattern with data-only on both platforms messaging().setBackgroundMessageHandler(async remoteMessage => { await notifee.displayNotification({ title: remoteMessage.data.title, body: remoteMessage.data.body, android: { channelId: remoteMessage.data.channelId || 'default' }, }); }); ``` …you're running into iOS silent-push throttling (30–60% loss) and hand-rolling the payload shape. The FCM Mode migration is: ### Step 1 — Server Replace your custom payload builder with `buildNotifyKitPayload`. If you were already sending data-only on iOS, the iOS half changes (you'll now emit an alert payload). Old payload: ```ts // OLD — manual await admin.messaging().send({ token, data: { title: '...', body: '...', channelId: 'orders' }, apns: { payload: { aps: { 'content-available': 1 } } }, // silent push }); ``` New payload: ```ts // NEW — FCM Mode await admin.messaging().send( buildNotifyKitPayload({ token, notification: { title: '...', body: '...', android: { channelId: 'orders' } }, }), ); ``` ### Step 2 — Client Swap `displayNotification` for `handleFcmMessage`: ```ts // OLD messaging().setBackgroundMessageHandler(async (m) => { await notifee.displayNotification({ title: m.data.title, body: m.data.body, ... }); }); // NEW messaging().setBackgroundMessageHandler(async (m) => { await notifee.handleFcmMessage(m); }); ``` And configure defaults once at startup: ```ts notifee.setFcmConfig({ defaultChannelId: 'default' }); ``` ### Step 3 — iOS For Expo CNG / development builds, add the config plugin and run prebuild. For bare React Native, run the CLI: `npx react-native-notify-kit init-nse && cd ios && pod install`. If you already had a Notify Kit Service Extension (e.g. from the legacy ObjC guide), you can either keep it and skip this step, or regenerate with `--force` to get the new Swift template. ### Compatibility during migration Old clients on old payloads keep working: the manual `displayNotification` path is unchanged. FCM Mode uses a **new data key** (`notifee_options`) that old clients don't read, so there's no on-the-wire breakage. You can roll out the server change first, then the client — old clients will continue to use `remoteMessage.data.title / .body` (FCM Mode's fallback path). ## Troubleshooting ### Expo Go does not work Expo Go cannot load `react-native-notify-kit` native code, the generated iOS NSE target, or the embedded `.appex`. Use Expo CNG / prebuild with a development build or EAS build. ### Expo prebuild did not generate `NotifyKitNSE` - Confirm the plugin is listed as `react-native-notify-kit` in `expo.plugins`. - Confirm `ios.bundleIdentifier` is set. The plugin requires it to derive the NSE bundle identifier. - Run a clean prebuild if the native folders were generated before adding the plugin: ```bash npx expo prebuild --clean --platform ios ``` - Inspect `ios/NotifyKitNSE/`, the Xcode project target list, and `extra.eas.build.experimental.ios.appExtensions` in the resolved Expo config. ### iOS notification not appearing in background - **Check the Notification Service Extension is installed.** In Xcode, look for the `NotifyKitNSE` target. If missing, use the Expo config plugin and rerun prebuild for Expo, or run `npx react-native-notify-kit init-nse` for bare React Native. - **Check NSE signing.** Targets → `NotifyKitNSE` → Signing & Capabilities. Team must match the main app; provisioning profile must cover `.NotifyKitNSE`. - **Check `aps-push-type: alert` and `mutable-content: 1` are present.** Both are emitted automatically by `buildNotifyKitPayload`; if they're missing, a middleware / proxy is stripping them. - **Attach to the NSE process in Xcode** (Debug → Attach to Process → `NotifyKitNSE`) and confirm `didReceive` fires. If nothing fires, APNs isn't routing to your NSE. ### Android duplicate notifications If you see two notifications per push on Android, the FCM SDK is auto-displaying the alert AND your `handleFcmMessage` is displaying a second one. Causes: - You're sending the push **without** `buildNotifyKitPayload` — something in your pipeline is setting a `notification` field on the Android message. FCM Mode always uses `data`-only on Android; check your payload via `gcloud logging read 'resource.type="logging_sink"'` or Firebase Console. - You have an older client (< 10.0.0) handling the message alongside a newer client. Bump all clients. ### NSE not activating - Run the app on a **real iOS device** (NSEs don't run on the simulator for remote pushes — local notifications only). - Check `aps-push-type` is `alert` and `mutable-content` is `1`. The server SDK always sets both. - Attach to the NSE process in Xcode and send a test push via `node scripts/send-test-fcm.ts` (requires `GOOGLE_APPLICATION_CREDENTIALS` + device token). If `didReceive` never fires, the NSE isn't linked — verify Xcode → General → Frameworks, Libraries, and Embedded Content lists `RNNotifeeCore.framework` (or the CocoaPods static-library equivalent). - For Expo, run a clean prebuild after changing plugin options and verify the generated `.appex` is embedded in the host app's `PlugIns` directory after build. ### Custom sound not playing Custom sounds have platform-specific requirements that don't go through the Notify Kit JS API when FCM delivers the push. - **iOS:** the sound file must be bundled in the **NSE target's** resources, not (only) the main app. Drag the file into the `NotifyKitNSE/` folder in Xcode and verify it appears in the NSE target's Build Phases → Copy Bundle Resources. - **Android:** the `NotificationChannel` sound is locked at channel creation. `notifee_options.android.sound` from FCM Mode overrides the channel sound only if the channel was created with that sound already. To change the sound, create a new channel under a new ID. See the main README's [custom sounds section](../README.md#custom-sounds-for-push-notifications-in-background-or-killed-state) for the full background. ### `pod install` fails after NSE generation If `pod install` errors with "Unable to find a specification for `RNNotifeeCore`" or similar: - Verify `node_modules/react-native-notify-kit/` exists and includes a `RNNotifeeCore.podspec`. - Run `pod install --repo-update` from `ios/`. - If you use Firebase with static frameworks, confirm the NSE target uses compatible `use_frameworks! :linkage => :static` linkage. The Expo plugin mirrors the detected Expo/Firebase static-framework setup when needed. - For bare React Native CLI output, the NSE block is normally nested inside the main app target with `inherit! :search_paths`. - For Expo plugin output, the NSE block is a top-level target and should not inherit the Expo host target module graph. - If it still fails, rerun a clean Expo prebuild or rerun `npx react-native-notify-kit init-nse --force` for bare React Native on a clean Podfile, then inspect the generated Podfile before applying local customizations. ### Xcode reports `Cycle inside ` after adding the NSE This can happen when the host app embeds `NotifyKitNSE.appex` and React Native Firebase adds its `[RNFB] Core Configuration` build phase. Current Expo plugin and `init-nse` versions add a Podfile `post_install` patch that removes the problematic RNFB input path after each `pod install`. If the cycle persists, update Notify Kit. For Expo, run a clean prebuild. For bare React Native, rerun `npx react-native-notify-kit init-nse`, then run `pod install` again from `ios/`. Avoid editing React Native Firebase build phases manually unless your project has additional custom build-phase inputs that still create a cycle. ### iOS attachment metadata exists but the attachment is not visible - Confirm every attachment URL is HTTPS. The server SDK rejects non-HTTPS iOS attachment URLs. - Confirm the URL is reachable by the device without app authentication. - Check NSE logs in Console.app by filtering for process `NotifyKitNSE`. - Inspect `getDisplayedNotifications()` for `ios.attachments` metadata. Metadata proves the NotifyKit payload path ran, but visual lock-screen/banner rendering still depends on iOS attachment download and presentation rules. ### `handleFcmMessage` returns `null` By design, when: - iOS + app in background or killed (NSE owns display). - `fallbackBehavior: 'ignore'` and the payload has no `notifee_options`. - `ios.suppressForegroundBanner: true` and the app is in iOS foreground. - The `remoteMessage` wasn't produced by NotifyKit and you opted out of the fallback. If you're seeing `null` unexpectedly, check `remoteMessage.data.notifee_options` — it should be a JSON string. If it's missing, your server isn't using `buildNotifyKitPayload`. ### Notification appears but tap doesn't open app On Android, set a `pressAction`: ```ts android: { pressAction: { id: 'default', launchActivity: 'default' } } ``` Or call `notifee.setFcmConfig({ defaultPressAction: { id: 'default', launchActivity: 'default' } })` at startup. Since [9.3.0](../CHANGELOG.md#930---2026-04-09) the library injects this default at the native layer for `displayNotification`, so `handleFcmMessage` tap behavior works without explicit config — but set it if you want the tap to route to a non-default activity. See the main README section on [Android `pressAction`](../README.md#android-pressaction-defaults-to-opening-the-app-on-tap). ### Payload too large The server SDK warns at ~3500 bytes. Common fixes: - Shorten `body` or move long text to Android `BIG_TEXT` style. - Collapse nested `data` values into a single JSON string. - Switch to a nudge-then-fetch pattern (push an ID, fetch the payload on open). ## Known limitations - **iOS background `DELIVERED` event gap.** When a push arrives while your app is in background/killed on iOS, `EventType.DELIVERED` is **not** emitted to `onBackgroundEvent` — the NSE draws the notification and the main app process never wakes. This is a platform limitation (no `UNUserNotificationCenterDelegate` callback fires for NSE-drawn notifications until the user taps). Android emits `DELIVERED` unconditionally. To detect background delivery on iOS, check `getDisplayedNotifications()` when the app returns to foreground, or have the NSE write to a shared App Group container. - **No deep validation of nested `android` / `ios` config.** The server SDK validates the top-level shape (routing, data value types, iOS attachment URLs, ttl, etc.) but trusts TypeScript structural typing for the nested `NotifyKitAndroidConfig` / `NotifyKitIosConfig`. JavaScript callers that bypass TypeScript should validate themselves. Deep runtime validation is planned. - **Style types limited to `BIG_TEXT` / `BIG_PICTURE`.** `MESSAGING`, `INBOX`, and `CALL` styles are available via `displayNotification` but aren't wired through the server SDK yet — their schemas need versioning (person avatars, reply actions) before the wire contract is frozen. - **Expo Go not supported.** Expo CNG / prebuild development builds are supported. Expo Go cannot load the native NotifyKit module, the generated iOS NSE target, or the embedded `.appex`. - **Firebase/RNFirebase setup is not owned by NotifyKit.** The NotifyKit config plugin does not create Firebase apps, install RNFirebase, copy `google-services.json`, configure `GoogleService-Info.plist`, or patch Gradle for Firebase. - **Android data-only delivery is conditional.** Background and killed-state data-only delivery depends on FCM priority, device state, Doze, and OEM policy. Use high priority for user-visible background validation, but do not treat delivery on every OEM as guaranteed. - **Android force-stop is excluded.** FCM and Android do not guarantee delivery after `adb shell am force-stop` or a user force-stop. Killed-state validation in 10.4.0 was best-effort only and did not include force-stop. - **Android Expo tap validation scope.** Android foreground/background delivery and tap routing were validated in the Expo smoke app on a Pixel 9 Pro XL, including `SMOKE:BACKGROUND_EVENT_PRESS` and optional `SMOKE:FOREGROUND_EVENT_PRESS`. This is not a guarantee for every device, OEM state, Doze state, or force-stop path. - **iOS Expo validation scope.** The 10.4.0 Expo gate verified runtime base behavior, foreground FCM delivery on a physical iPhone, logical `ios.attachments` metadata, and the `NotifyKitNSE` process. iOS visible FCM background delivery and tap-to-open were observed, but JS `PRESS` marker validation for background tap remains a follow-up. It did not verify iOS killed state, visual lock-screen/banner attachment rendering, textual NSE log capture, or iOS RNFirebase data-only/client-handler behavior. - **Android foreground service plugin scope.** The plugin configures NotifyKit foreground service manifest requirements only when explicitly opted in. The 10.4.0 runtime gate validated `shortService` on a Pixel 9 Pro XL, but other foreground service types were not runtime-validated. It does not add exact-alarm automation, full-screen intent automation, Firebase setup, Maven/Gradle repository workarounds, or Google Play policy approval. - **RNFirebase modular API warning cleanup.** The Expo smoke app was migrated to React Native Firebase modular APIs to remove the namespaced API deprecation warning from the smoke paths. This is smoke fixture cleanup only and does not change the public NotifyKit API or add a consumer requirement. - **The CLI creates the NSE target once.** Re-running `init-nse` without `--force` is a no-op. Re-running with `--force` will overwrite `NotificationService.swift` — back it up first if you've customized the file. ## Comparison with other libraries | Feature | NotifyKit FCM Mode | Manual `@notifee` + RNFB | OneSignal | `expo-notifications` | | --------------------------------- | ------------------------------------------------------------------ | -------------------------- | -------------------------- | ------------------------------------- | | Android display | `displayNotification` from headless task | Same | OneSignal SDK | `expo-notifications` | | iOS background reliability | APNs alert (~99%) via NSE | Data-only silent (~40-70%) | APNs alert (proprietary) | APNs alert (Expo-managed) | | iOS NSE setup | Expo config plugin or bare RN CLI | Manual Xcode steps | Bundled, closed-source | Managed (no NotifyKit NSE) | | Backend SDK | `react-native-notify-kit/server` (zero deps) | Hand-rolled FCM v1 | OneSignal REST API | Expo push REST API | | Backend runs on | Any Node.js (CFns, Lambda, self-hosted) | Any Node.js | OneSignal (vendor lock) | Expo servers (vendor lock-ish) | | Notification styling | BIG_TEXT, BIG_PICTURE, actions, attachments, categories, thread-id | Full Notify Kit surface | OneSignal-specific | Limited (no custom styles on Android) | | Rich iOS notifications | Yes, via NSE blob | Yes, via NSE blob | Yes | Limited | | Foreground services | Via `displayNotification` | Same | Not supported | Not supported | | Trigger notifications (scheduled) | Full Notify Kit (AlarmManager) | Same | OneSignal scheduling | Basic (local) | | Source availability | Apache-2.0, open source | Same | Closed source | Apache-2.0, vendor-tied | | Data-only pushes | Supported (fall through to `displayNotification`) | Supported | Supported | Limited | | Works without FCM | No (FCM is the transport) | No | Proprietary transport | Expo transport | | Monthly active device limit | FCM free (unlimited) | FCM free | Free tier cap, paid beyond | Free | --- See the [main README](../README.md) for Notify Kit's full feature surface, the [server SDK README](../packages/react-native/server/README.md) for a compact server reference, and the [CHANGELOG](../CHANGELOG.md) for release history.