# 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 scaffolding the NSE with `npx react-native-notify-kit init-nse` is the end-to-end fix — see the architecture and setup sections below. 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) - [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 **CLI** (`npx react-native-notify-kit init-nse`) scaffolds the iOS NSE target, patches the Podfile, and wires the `.pbxproj` — the one-time iOS setup is a single command. 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 can't run a Notification Service Extension (Expo managed workflow currently — see [Known limitations](#known-limitations)). ## 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: scaffold the NSE From your project root: ```bash npx react-native-notify-kit init-nse cd ios && pod install ``` This creates a `NotifyKitNSE` target, patches the Podfile, and wires `.pbxproj`. Open `ios/.xcworkspace` in Xcode, verify the NSE target's signing, then build. 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. - Fire the `EventType.PRESS` event on tap, with `detail.notification.data` containing your custom `data` fields. Keep reading for the full API surface and error cases. ## 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` | headless task runs, `displayNotification(...)` — notification appears | | 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 ships with a CLI that scaffolds this for you. ### Automated setup (recommended) ```bash npx react-native-notify-kit init-nse cd ios && pod install ``` What the 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 the CLI doesn't work for your project (Expo managed workflow, heavily customized Xcode configs, 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 `--force` will 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 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 ### iOS notification not appearing in background - **Check the Notification Service Extension is installed.** In Xcode, look for the `NotifyKitNSE` target. If missing, run `npx react-native-notify-kit init-nse`. - **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). ### 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 `init-nse` 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 `use_frameworks! :linkage => :static`, the 9.1.22 duplicate-symbol fix is in effect — no extra config needed. - If it still fails, inspect the generated Podfile diff — the NSE block should be nested inside the main app target with `inherit! :search_paths`. If it's at the top level, your Podfile has a shape the patcher didn't recognize — run `npx react-native-notify-kit init-nse --force` on a clean Podfile, or patch manually using the [legacy guide](../apps/smoke/NOTIFICATION_SERVICE_EXTENSION.md). ### 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 `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, 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. ### `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 managed workflow not supported.** The CLI writes directly to `ios/` and assumes a bare React Native project. An Expo config plugin (`expo-notify-kit-config`) is on the roadmap. In the meantime, prebuild (`npx expo prebuild`) and run the CLI against the generated `ios/` directory. - **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 | One CLI command | Manual Xcode steps | Bundled, closed-source | Managed (no NSE needed) | | 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.