> ## Documentation Index
> Fetch the complete documentation index at: https://docs.page/marcocrupi/react-native-notify-kit/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 the supported channel, style, and action fields.
- **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 want one Server SDK call for the supported Android and iOS fields 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, plus optional subtitle, 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 `<ios.bundleIdentifier>.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 Firebase Admin-compatible message 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. Firebase Admin serializes the message to the FCM HTTP v1 request format.

```ts
const message = buildNotifyKitPayload({
  token: 'eZ...device token',
  notification: { id, title, subtitle?, 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; // public NotifyKit notification ID; also defaults the transport collapse key
  title: string; // required, non-empty
  subtitle?: string;
  body: string; // required, non-empty
  data?: Record<string, string>;
  android?: NotifyKitAndroidConfig;
  ios?: NotifyKitIosConfig;
};

type NotifyKitAndroidConfig = {
  channelId?: string;
  smallIcon?: string;
  largeIcon?: string;
  circularLargeIcon?: boolean;
  color?: string;
  pressAction?: NotifyKitPressAction | null;
  actions?: NotifyKitAndroidAction[];
  style?:
    | { type: 'BIG_TEXT'; text: string; title?: string; summary?: string }
    | {
        type: 'BIG_PICTURE';
        picture: string;
        title?: string;
        summary?: string;
        largeIcon?: string | null;
      };
  showTimestamp?: boolean; // show timestamp when timestamp is set
  timestamp?: number; // milliseconds; used for display and notification-panel sorting
};

type NotifyKitPressAction = {
  id: string;
  launchActivity?: string;
  mainComponent?: string;
  launchActivityFlags?: AndroidLaunchActivityFlag[]; // numeric enum values 0–20
};

type NotifyKitAndroidAction = {
  title: string;
  pressAction: NotifyKitPressAction;
  icon?: string;
  input?:
    | true
    | {
        allowFreeFormInput?: boolean;
        allowGeneratedReplies?: boolean;
        choices?: string[];
        editableChoices?: boolean;
        placeholder?: 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;
};
```

These are the Batch 2A additions to the supported FCM Mode input, `_v: 1` wire blob, and client reconstruction. Display still depends on the platform and notification layout:

| Input field                                | Wire and reconstruction                                                                    | Display scope                                                                                                          |
| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `notification.subtitle`                    | Optional top-level string in `notifee_options`; also copied to iOS `aps.alert.subtitle`    | Android display qualified on Pixel 9 Pro XL / Android 17. iOS path checked statically; iOS device runtime **NOT_RUN**. |
| `notification.android.circularLargeIcon`   | Optional boolean retained as `true` or `false`; omission remains absent                    | Android large icon cropping: `true` circular, `false` square; omission keeps the existing default.                     |
| `notification.android.style` `BIG_TEXT`    | Required `text`; optional `title` and `summary` retained                                   | Android expanded style; visual presentation belongs to Android.                                                        |
| `notification.android.style` `BIG_PICTURE` | Required `picture`; optional `title`, `summary`, and `largeIcon` (string or null) retained | Android expanded style; `largeIcon: null` hides the expanded large icon and differs from omission.                     |

This is a limited FCM Mode contract, not full parity with the public `Notification` / `NotificationAndroid` APIs. Batch 2B adds only the [Android interactions described below](#body-press-actions-and-action-buttons); it does not add further style types or other notification fields.

### Notification identity and transport collapse

FCM Mode keeps four identities separate:

| Concept                                    | Meaning                                                                                                          |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `notification.id` (N)                      | Logical/public NotifyKit notification identity for display, events, readback, and cancel.                        |
| `options.collapseKey` (C, when set)        | FCM/APNs transport collapse or coalescing identity. The effective C is `options.collapseKey ?? notification.id`. |
| Firebase `messageId` (M)                   | Identity of an FCM transport message, not the requested NotifyKit notification ID.                               |
| iOS `UNNotificationRequest.identifier` (R) | Physical request identity assigned on the APNs/iOS delivery path, separate from NotifyKit's logical ID.          |

When you provide `notification.id`, the Server SDK serializes N in the unchanged `_v: 1` `notifee_options` blob. Android uses N to display or replace a delivered notification with the same ID; `PRESS` / `ACTION_PRESS` events, `getDisplayedNotifications()`, and `cancelNotification(N)` use N. `buildFcmNotification()` also returns N so a manual `displayNotification()` call retains it. On iOS, foreground NotifyKit display uses N. The NSE preserves N as the logical ID without changing R; NotifyKit `PRESS` / `ACTION_PRESS` events, readback, and cancel use N. If several delivered iOS requests have different R values but the same N, `cancelNotification(N)` removes all of them. iOS `ACTION_PRESS` identity was not run on a physical device.

For example, `notification: { id: 'order-42', ... }` with `options: { collapseKey: 'order-updates' }` keeps `order-42` as the public notification ID while using `order-updates` for transport coalescing. When `collapseKey` is omitted, C defaults to N; when both are absent, no collapse key is emitted. If N is absent from a legacy `_v: 1` payload, the existing path-specific fallback remains: Firebase M in client reconstruction when available, physical R for iOS NSE notifications, or a generated ID where applicable.

Collapse can coalesce or replace messages still awaiting delivery. **Transport collapse does not guarantee replacement of a notification already displayed.** On iOS, APNs retains its own physical request identity tied to delivery and collapse; when N differs from C, N still governs NotifyKit events, readback, and cancel, but N alone does not control APNs physical merging. In a physical iOS run, the NSE processed a second push with the same N and C while Notification Center still showed the first push's content. A separate offline-device check confirmed transport coalescing: only the second message arrived after reconnection.

The effective APNs collapse ID, `options.collapseKey ?? notification.id`, must be at most **64 UTF-8 bytes**. This is a limit on C, not a general limit on N when a valid separate `collapseKey` is provided.

The returned value is a Firebase Admin-compatible message with exactly one routing target:

```ts
type NotifyKitPayloadTarget =
  | { token: string; topic?: never; condition?: never }
  | { topic: string; token?: never; condition?: never }
  | { condition: string; token?: never; topic?: never };

type NotifyKitPayloadOutput = NotifyKitPayloadTarget & {
  data: Record<string, string>; // your data keys + notifee_options
  android: {
    priority: 'high' | 'normal';
    collapseKey?: string;
    ttl?: number; // milliseconds; options.ttl input seconds × 1000
  };
  apns: {
    headers: {
      'apns-push-type': 'alert';
      'apns-priority': '10';
      'apns-collapse-id'?: string;
      'apns-expiration'?: string; // absolute UNIX epoch in seconds
    };
    payload: {
      aps: {
        alert: { title; body; subtitle? };
        'mutable-content': 1;
        sound?;
        category?;
        'thread-id'?;
        'interruption-level'?;
        badge?;
      };
      notifee_options: string; // JSON blob, see Payload reference
      notifee_data?: string; // present when notification.data has user data
    };
  };
  sizeBytes: number; // non-enumerable — not serialized with JSON.stringify
};
```

Top-level `data` contains the user data keys and `notifee_options`. `notifee_data` is added only to `apns.payload` when user data is present; there is no key-count threshold.

On iOS, the NSE reads `apns.payload.notifee_data` and puts those string keys into the delivered notification's NotifyKit `data` and event path. The original APNs/FCM transport fields and NotifyKit event metadata remain authoritative if a custom key has the same name. For older payloads with `notifee_options.data`, those values are preserved; when both data sources contain the same key, `notifee_data` takes precedence within the notification's `data`.

`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,
  // Firebase Admin-compatible output shapes
  NotifyKitAndroidOutput,
  NotifyKitApnsOutput,
  NotifyKitApnsAps,
  NotifyKitApnsHeaders,
  NotifyKitApnsPayload,
  SerializedNotifeeOptions,
  ApnsInterruptionLevel,
} from 'react-native-notify-kit/server';
```

### Validation rules

The following Server SDK errors are thrown synchronously from `buildNotifyKitPayload`. Their 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: <n>`                                                                |
| `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.subtitle` string when provided      | `Validation: notification.subtitle must be a string when provided`                                                                                   |
| `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 <type> for key '<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`                                         |
| `android.circularLargeIcon` boolean               | `Android: notification.android.circularLargeIcon must be a boolean when provided`                                                                    |
| Body `android.pressAction` object                 | `Android: notification.android.pressAction.id must be a non-empty string` or a field-specific error; explicit `null` is accepted                     |
| Action shape and `pressAction`                    | `Android: notification.android.actions must be an array` or an action/press-action field-specific error                                              |
| Action `icon`                                     | `Android: notification.android.actions[<index>].icon must be a non-empty string`                                                                     |
| Action `input`                                    | `Android: notification.android.actions[<index>].input must be true or an AndroidInput object` or an input-field-specific error                       |
| `android.style` supported object and type         | `Android: notification.android.style must be a supported style object` or `Android: notification.android.style type must be BIG_TEXT or BIG_PICTURE` |
| `android.style` supported keys only               | `Android: notification.android.style.<key> is not supported`                                                                                         |
| `BIG_TEXT.text` / `BIG_PICTURE.picture` required  | `Android: notification.android.style.text must be a non-empty string` or `Android: notification.android.style.picture must be a non-empty string`    |
| Style `title` / `summary` strings                 | `Android: notification.android.style.title must be a string when provided` or the corresponding `summary` error                                      |
| `BIG_PICTURE.largeIcon` string or null            | `Android: notification.android.style.largeIcon must be a string or null`                                                                             |
| 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: <url>`                                                                                             |
| iOS interruptionLevel enum                        | `Validation: invalid interruptionLevel '<value>'. 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: <value>`                                                                     |
| `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: <value>`                                                                         |
| `options.collapseKey` non-empty                   | `Validation: 'options.collapseKey' must be a non-empty string`                                                                                       |
| Effective APNs collapse ID at most 64 UTF-8 bytes | `Validation: apns-collapse-id must be at most 64 UTF-8 bytes`                                                                                        |
| `pressAction.id` unique in `android.actions`      | `Android: 'notification.android.actions' pressAction IDs must be unique within the notification`                                                     |
| Blob must be serializable                         | `Serialization: notifee_options contains circular references or non-serializable values`                                                             |

For hand-crafted or legacy `_v: 1` Android interaction blobs, an absent body `pressAction` can use the configured/default tap action, while `pressAction: null` remains an explicit opt-out. Invalid non-null body `pressAction` values and invalid action-level `pressAction` values are rejected rather than treated as absent. Present non-array `actions`, malformed action entries (including `null`; sparse slots become `null` when serialized as JSON), and invalid values anywhere in `launchActivityFlags` are rejected before native display. Legacy action `input: false` still means no input; it is not accepted in newly built Server SDK payloads.

> **Note on `ttl: 0`.** Zero is rejected because it's semantically ambiguous ("never expire" vs "expire immediately"). Omit `ttl` entirely to use FCM's default (4 weeks), or pass a positive integer in seconds.

<!-- Keep these callouts separate. -->

> **Note on `firebase-admin` TTL compatibility.** `options.ttl` remains expressed in seconds. `buildNotifyKitPayload()` converts the Android value to the millisecond duration required by Firebase Admin (`ttl: 3600` becomes `android.ttl: 3_600_000`), so the message can be passed directly to `admin.messaging().send()`. APNs expiration remains an absolute UNIX epoch timestamp in seconds.

### 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 — `buildFcmNotification`, `handleFcmMessage`, and `setFcmConfig` are instance methods on the singleton.

### `notifee.handleFcmMessage(remoteMessage): Promise<string | null>`

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<string, string>;
  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: <detail>. 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 <N> is newer than supported version 1. Display may be incomplete.`
- `[react-native-notify-kit] android.style.type '<type>' present but required '<field>' field missing or not a string. Style ignored.`
- `[react-native-notify-kit] Unknown android.style.type '<type>'. Style ignored.`
- `[react-native-notify-kit] Unknown ios.interruptionLevel '<level>'. 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.buildFcmNotification(remoteMessage): Notification | null`

Builds a `Notification` from an FCM remote message using the same config-aware reconstruction pipeline as `handleFcmMessage`, but does not display it.

Use it when your app needs to inspect or customize the reconstructed notification before displaying it manually. For example, an app can apply its own image URL as an Android BigPicture style:

```ts
import notifee, { AndroidStyle } from 'react-native-notify-kit';

const notification = notifee.buildFcmNotification(remoteMessage);

if (notification) {
  const imageUrl = getImageUrlFromYourAppOrBackend(remoteMessage);

  if (imageUrl) {
    notification.android = {
      ...notification.android,
      style: {
        type: AndroidStyle.BIGPICTURE,
        picture: imageUrl,
      },
    };
  }

  await notifee.displayNotification(notification);
}
```

The return value is a pre-validation notification. When `notifee_options` contains `id`, the returned notification keeps it. For a legacy payload without `id`, the client uses Firebase `messageId` when available; otherwise `id` may be absent until `displayNotification` applies final validation and defaults.

It returns `null` only when the FCM payload parser returns `null` and `fallbackBehavior` is `'ignore'`.

`buildFcmNotification` does not call `displayNotification`, does not apply iOS display policies such as `suppressForegroundBanner`, and does not read `AppState`. `handleFcmMessage` retains its existing iOS policy, including `suppressForegroundBanner`. When you display a built notification manually, your app is responsible for the display policy.

`buildFcmNotification` does not automatically map standard or custom FCM image fields to `AndroidStyle.BIGPICTURE`; choose and apply any image mapping explicitly in your app.

**Avoid duplicate display.** Do not call both `handleFcmMessage` and `buildFcmNotification` for the same remote message. Choose one path:

- `handleFcmMessage` for automatic build and display.
- `buildFcmNotification` for build, manual patching, and manual display.

### `notifee.setFcmConfig(config): Promise<void>`

Sets defaults that `buildFcmNotification` and `handleFcmMessage` consult 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: <type>` 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.

### iOS custom data and badge

The Server SDK serializes `notification.data` as `apns.payload.notifee_data`. The production NSE merges it into the delivered notification's `data`, which is available on the NotifyKit notification/event path when the user taps. Custom data cannot replace top-level APNs/FCM fields or NotifyKit event metadata. Background delivery-to-tap was validated on a physical iPhone 16e running iOS 26.5; the killed/cold-start data path was not run.

Use the Server SDK's `options.iosBadgeCount?: number` to set `aps.badge`. Its supported input remains a finite non-negative integer; `null` is rejected. `NotifyKitIosConfig` does not expose `badgeCount`.

| Server SDK input                     | APNs field     | NSE result                                         |
| ------------------------------------ | -------------- | -------------------------------------------------- |
| Positive finite integer, such as `7` | `aps.badge: 7` | Preserves `7`                                      |
| `0`                                  | `aps.badge: 0` | Preserves `0` and clears the app icon badge        |
| Omitted                              | No `aps.badge` | Leaves the badge unset; omission does not mean `0` |

For legacy/manual `_v: 1` payloads, a numeric `notifee_options.ios.badgeCount` keeps precedence over `aps.badge`, including an override of `0`. If an existing payload contains `notifee_options.ios.badgeCount: null`, the NSE treats it as an absent override. That legacy `null` is removed from the badge options before native content is built, so it is not forwarded as `NSNull` to the native badge setter.

| Legacy `notifee_options.ios.badgeCount` | APNs `aps.badge` | NSE result                         |
| --------------------------------------- | ---------------- | ---------------------------------- |
| Absent or legacy `null`                 | Absent           | Leaves the current badge unchanged |
| Absent or legacy `null`                 | `0`              | Clears the app icon badge          |
| Absent or legacy `null`                 | Positive integer | Preserves the APNs badge value     |
| Numeric override, including `0`         | Any or absent    | Uses the numeric legacy override   |

This defensive compatibility handling applies to existing legacy/manual payloads and does not make `null` a supported Server SDK input. Use `options.iosBadgeCount` for new sends.

Physical iPhone 16e / iOS 26.5 qualification passed T0 and all eight real APNs/NSE badge scenarios (B1–B8): legacy `null`/absent with APNs absent/`0`/`7`, and numeric overrides `3`/`0` with APNs `7`.

iOS/SpringBoard can also withdraw delivered notifications when a badge is set to `0`. This is platform-owned behavior, not an explicit NotifyKit cancellation; clearing the badge does not guarantee that a notification stays in Notification Center.

### 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 = <parent-bundle-id>.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 <path>`     | auto-detect     | Path to your iOS directory (e.g. `ios/`).                                                                 |
| `--target-name <name>`  | `NotifyKitNSE`  | NSE target name. Must match `/^[A-Za-z0-9_\-.]+$/`.                                                       |
| `--bundle-suffix <str>` | `.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 '<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 '<suffix>'. Must start with '.' and contain only letters, digits, hyphens, and dots.`
- `NSE target '<name>' already exists in <where>.\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.

Supported Android notification fields, including `showTimestamp`, `timestamp`, `circularLargeIcon`, supported styles, and the interaction fields below, are serialized inside the `_v: 1` `notifee_options` blob and reconstructed client-side by `handleFcmMessage`. They are not mapped to Firebase Admin's `android.notification` object.

### 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',
  largeIcon: 'https://cdn.example.com/avatar.png',
  circularLargeIcon: false,
  style: {
    type: 'BIG_PICTURE',
    picture: 'https://cdn.example.com/banner.png',
    title: 'Expanded title',
    summary: 'Expanded summary',
    largeIcon: null,
  },
}

// Client receives (and calls displayNotification with):
android: {
  channelId: 'news',
  largeIcon: 'https://cdn.example.com/avatar.png',
  circularLargeIcon: false,
  style: {
    type: AndroidStyle.BIGPICTURE,
    picture: 'https://cdn.example.com/banner.png',
    title: 'Expanded title',
    summary: 'Expanded summary',
    largeIcon: null,
  },
}
```

For `BIG_TEXT`, send `{ type: 'BIG_TEXT', text: 'Long text', title: 'Expanded title', summary: 'Expanded summary' }`; the client maps the type to `AndroidStyle.BIGTEXT` and keeps the other fields. `title` and `summary` apply to the expanded style. For `BIG_PICTURE`, a string `style.largeIcon` overrides the main `android.largeIcon` while expanded; explicit `null` hides it while expanded; omission leaves that override unset. `circularLargeIcon: false` is retained separately from an omitted field, even though the default is also non-circular. Actual layout and image rendering belong to Android.

Other `AndroidStyle` values (`MESSAGING`, `INBOX`, `CALL`) aren't wired through the server SDK. Build them yourself via `displayNotification` until then.

### Body press actions and action buttons

For `notification.android.pressAction`:

| Value  | Body-tap behavior                                                                                                                           |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Absent | Keeps the FCM Mode `defaultPressAction` if configured; otherwise the normal Android default opens the app.                                  |
| `null` | Explicitly disables the body tap, even when a default is configured. No body-tap app launch or `PRESS` event; action buttons remain usable. |
| Object | Uses its `id` and optional `launchActivity`, `mainComponent`, and `launchActivityFlags`.                                                    |

The same four object fields are supported on each action's `pressAction`. `launchActivityFlags` takes public `AndroidLaunchActivityFlag` enum values; `mainComponent` requires an appropriately registered React component and native setup. An alternate `mainComponent` passed contract and client checks but was **not run** on the physical device.

```ts
// Explicitly disable the notification body tap:
android: { channelId: 'orders', pressAction: null }

// Or configure it (after registering Orders and completing its native setup):
android: {
  channelId: 'orders',
  pressAction: {
    id: 'open-order',
    launchActivity: 'default',
    mainComponent: 'Orders',
    launchActivityFlags: [AndroidLaunchActivityFlag.CLEAR_TOP],
  },
}
```

Actions require a `title` and `pressAction`. Optional `icon` is a local or remote icon path; Android may omit action icons from the notification shade. The Pixel 9 Pro XL / Android 17 qualification confirmed the icon's data/native path, not its visual display.

For action replies, omit `input` for no reply, set `input: true` for the standard free-text direct reply, or provide an `AndroidInput` object to configure it. The supported object fields are:

| `AndroidInput` field              | Meaning                                                                            |
| --------------------------------- | ---------------------------------------------------------------------------------- |
| `allowFreeFormInput?: boolean`    | Allows typed text; defaults to `true`. If `false`, supply non-empty `choices`.     |
| `allowGeneratedReplies?: boolean` | Allows device-generated suggestions when available; defaults to `true`.            |
| `choices?: string[]`              | Non-empty list of suggested or required reply choices.                             |
| `editableChoices?: boolean`       | Controls whether a selected choice can be edited; `true` requires free-form input. |
| `placeholder?: string`            | Text shown in the reply field.                                                     |

The Server SDK rejects a new `input: false`. For compatibility only, client reconstruction treats `input: false` in an older `_v: 1` blob as absent input. Other malformed legacy input values are not silently discarded; reconstruction or normal validation rejects them.

```ts
android: {
  actions: [
    { title: 'Accept', pressAction: { id: 'accept-order' }, icon: 'ic_accept' },
    { title: 'Decline', pressAction: { id: 'decline-order' } },
    { title: 'Reply', pressAction: { id: 'reply' }, input: true },
    {
      title: 'Choose',
      pressAction: { id: 'choose' },
      input: { allowFreeFormInput: false, choices: ['Now', 'Later'] },
    },
  ],
}
```

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,
  "id": "order-42",
  "title": "Order received",
  "subtitle": "Order update",
  "body": "We're preparing your food.",

  "android": {
    "channelId": "orders",
    "smallIcon": "ic_notification",
    "largeIcon": "https://cdn.example.com/avatar.png",
    "circularLargeIcon": false,
    "color": "#4CAF50",
    "pressAction": {
      "id": "open-order",
      "launchActivity": "default",
      "mainComponent": "Orders",
      "launchActivityFlags": [4],
    },
    "actions": [
      { "title": "Accept", "pressAction": { "id": "accept" }, "icon": "ic_accept" },
      { "title": "Reply", "pressAction": { "id": "reply" }, "input": true },
      {
        "title": "Choose",
        "pressAction": { "id": "choose" },
        "input": { "allowFreeFormInput": false, "choices": ["Now", "Later"] },
      },
    ],
    "style": {
      "type": "BIG_TEXT",
      "text": "Long body text ...",
      "title": "Expanded title",
      "summary": "Expanded summary",
    },
  },

  "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 still carries `_v: 1`, including the Batch 2A, 2B, and 3 fields; the wire version did not change. 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` or Android `BIG_TEXT` text — both contribute to the serialized payload size. Shorten them or send an ID and fetch the full text when the app opens.
- 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 `<your-bundle-id>.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:** FCM Mode displays its data-only message through the app's Android notification channel. Configure the desired sound when creating that channel; its sound cannot be changed in place. The Server SDK's `NotifyKitAndroidConfig` has no `sound` field, and `notifee_options.android.sound` is not an FCM Mode wire option. To use another sound, create a channel with a new ID and select it via `notification.android.channelId`.

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 <AppName>` 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`.

### Android interaction payload is rejected

If `handleFcmMessage` rejects an Android interaction payload, inspect `notifee_options.android.pressAction` and `notifee_options.android.actions`. Omit the body `pressAction` to use the default, set it to `null` to disable the body tap, or provide a valid press-action object. Invalid non-null body values and invalid action press actions cannot act as omitted fields. `actions` must be an array of valid action objects, and every `launchActivityFlags` entry must be a valid `AndroidLaunchActivityFlag` value. These invalid interaction payloads fail before the native display call. Older `_v: 1` action entries with `input: false` remain compatible and are treated as having no input.

### Notification appears but tap doesn't open app

An absent Android body `pressAction` uses the configured FCM Mode default, if present, or the normal app-opening default. If the payload explicitly contains `pressAction: null`, the body tap is intentionally disabled; remove `null` or send a press-action object to enable it. For a custom tap target, 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 the app-opening default at the native layer for `displayNotification`. See the main README section on [Android `pressAction`](../README.md#android-pressaction-defaults-to-opening-the-app-on-tap) and the [FCM Mode interaction contract](#body-press-actions-and-action-buttons).

### Payload too large

The server SDK warns at ~3500 bytes. Common fixes:

- Shorten `body` and any Android `BIG_TEXT` text; both count toward the serialized payload size.
- 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.
- **Limited deep validation of nested `android` / `ios` config.** The Server SDK validates the top-level shape (routing, data value types, iOS attachment URLs, ttl, etc.), `android.circularLargeIcon`, supported Android styles, and the [Batch 2B interaction fields](#body-press-actions-and-action-buttons). Other nested fields still rely on TypeScript structural typing; JavaScript callers that bypass TypeScript should validate them. Hand-crafted legacy blobs do not pass through Server SDK validation. Broader deep runtime validation is planned.
- **Legacy Android wire parsing is not a general validation gate.** Invalid non-null body or invalid action `pressAction` values, non-array `actions`, malformed action entries, and invalid launch flags are rejected before native display. Other nested Android fields retain their existing reconstruction and validation behavior.
- **FCM Mode is a subset of the public notification API.** The [supported fields](#server-sdk-reference) and [Android interactions](#body-press-actions-and-action-buttons) do not imply full `Notification` / `NotificationAndroid` parity. Other notification fields and interaction behaviors remain outside this contract.
- **Style types limited to `BIG_TEXT` / `BIG_PICTURE`.** `MESSAGING`, `INBOX`, and `CALL` are available via `displayNotification` but aren't wired through the server SDK.
- **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              | Selected: `subtitle`, `circularLargeIcon`, `BIG_TEXT` / `BIG_PICTURE` | 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.
