# Handle notifications

The snippets below show the smallest possible handler — just enough to illustrate which fields to read and where the Stream payload lives in each library. Production apps need to add permission requests, channel setup, background handling and tap navigation. See the platform-specific guides for the full setup.

<tabs>

<tabs-item value="rn" label="React Native CLI" default>

Use `@react-native-firebase/messaging` to receive FCM messages on Android and `@react-native-community/push-notification-ios` to handle taps on iOS. Display the local notification with `@notifee/react-native` on Android (iOS renders the banner natively from `aps.alert`).

### Notifications displaying

<tabs>

<tabs-item value="android" label="Android" default>

On Android, Stream sends data-only FCM messages, so the OS doesn't render anything on its own. Register both a background and a foreground handler — `setBackgroundMessageHandler` covers killed state, `onMessage` covers foreground:

```ts
import { isFirebaseStreamVideoMessage } from "@stream-io/video-react-native-sdk";
import messaging from "@react-native-firebase/messaging";
import notifee from "@notifee/react-native";

async function displayStreamNotification(data: any) {
  if (data?.sender !== "stream.video" || data?.type === "call.ring") return;

  await notifee.displayNotification({
    title: "Call notification",
    body: `${data.created_by_display_name ?? "Someone"} is notifying you about a call`,
    data: { call_cid: data.call_cid, type: data.type, sender: "stream.video" },
    android: { channelId: "stream_non_ringing_calls" },
  });
}

// Background / killed state — must be registered at module init.
messaging().setBackgroundMessageHandler(async (msg) => {
  if (isFirebaseStreamVideoMessage(msg)) {
    await displayStreamNotification(msg.data);
  }
});

// Foreground
messaging().onMessage(async (msg) => {
  if (isFirebaseStreamVideoMessage(msg)) {
    await displayStreamNotification(msg.data);
  }
});
```

</tabs-item>

<tabs-item value="ios" label="iOS">

On iOS, Stream sends a standard APNs alert push — `aps.alert.title` / `aps.alert.body` are populated from the call type's push template, so iOS renders the banner natively in foreground, background, and killed states. You don't need to listen for incoming pushes or build a local notification.

</tabs-item>

</tabs>

### Interaction handling

<tabs>

<tabs-item value="android" label="Android" default>

Notifee surfaces taps for notifications it displayed. Register foreground / background event handlers plus a cold-start check:

```ts
import notifee, { EventType } from "@notifee/react-native";

function handleTap(data: any) {
  if (data?.sender !== "stream.video") return;
  // data.type, data.call_cid are available here.
  // Navigate to the call screen using your navigation method.
}

notifee.onForegroundEvent(({ type, detail }) => {
  if (type === EventType.PRESS) handleTap(detail.notification?.data);
});

notifee.onBackgroundEvent(async ({ type, detail }) => {
  if (type === EventType.PRESS) handleTap(detail.notification?.data);
});
```

</tabs-item>

<tabs-item value="ios" label="iOS">

Read the Stream payload (nested under `stream` in the APNs `userInfo`) when the user interacts with the banner. `@react-native-community/push-notification-ios` exposes the tap via the `localNotification` event:

```ts
import PushNotificationIOS from "@react-native-community/push-notification-ios";

function handleTap(stream: any) {
  if (stream?.sender !== "stream.video" || stream.type === "call.ring") return;
  // stream.type, stream.call_cid, stream.created_by_display_name are available here.
  // Navigate to the call screen using your navigation method.
}

PushNotificationIOS.addEventListener("localNotification", (notification) => {
  handleTap(notification.getData()?.stream);
});
```

</tabs-item>

</tabs>

<admonition type="info">

For the full list of fields available on the Stream payload (`data` on Android, `stream` on iOS), see the [Payload shape](/video/docs/react-native/incoming-calls/non-ringing-notifications-setup/overview/#payload-shape) section in the overview.

</admonition>

</tabs-item>

<tabs-item value="expo" label="Expo">

`expo-notifications` normalizes both platforms into a single `Notification` object. The platform-specific reception/display work is different, but **tap handling is the same** on both platforms — a single listener covers Android and iOS.

### Per-platform reception / display

<tabs>

<tabs-item value="android" label="Android" default>

On Android, Stream sends data-only FCM messages, so the OS doesn't render anything on its own. Register both a background and a foreground handler so killed-state pushes also render — `setBackgroundMessageHandler` covers killed state, `onMessage` covers foreground:

```ts
import { isFirebaseStreamVideoMessage } from "@stream-io/video-react-native-sdk";
import * as Notifications from "expo-notifications";
import messaging from "@react-native-firebase/messaging";

async function displayStreamNotification(data: any) {
  if (data?.sender !== "stream.video" || data?.type === "call.ring") return;

  await Notifications.scheduleNotificationAsync({
    content: {
      title: "Call notification",
      body: `${data.created_by_display_name ?? "Someone"} is notifying you about a call`,
      data: {
        call_cid: data.call_cid,
        type: data.type,
        sender: "stream.video",
      },
    },
    trigger: null,
  });
}

// Background / killed state — must be registered at module init.
messaging().setBackgroundMessageHandler(async (msg) => {
  if (isFirebaseStreamVideoMessage(msg)) {
    await displayStreamNotification(msg.data);
  }
});

// Foreground
messaging().onMessage(async (msg) => {
  if (isFirebaseStreamVideoMessage(msg)) {
    await displayStreamNotification(msg.data);
  }
});
```

</tabs-item>

<tabs-item value="ios" label="iOS">

On iOS, Stream sends a standard APNs alert push — `aps.alert.title` / `aps.alert.body` are populated from the call type's push template, so iOS renders the banner natively in foreground, background, and killed states. You don't need to schedule a local notification.

The one thing you do need is to opt the app in to showing notifications while it's foregrounded — `expo-notifications` suppresses foreground banners by default:

```ts
import * as Notifications from "expo-notifications";

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});
```

</tabs-item>

</tabs>

### Tap handling (cross-platform)

`addNotificationResponseReceivedListener` fires for foreground and background taps on both platforms, and `getLastNotificationResponse()` returns the notification that launched the app from a killed state. Stream's payload lives in different places per platform — flat in `content.data` on Android, nested under `trigger.payload.stream` on iOS — so use `Platform.select` to pick the right source, then fall back to `data` for the Android shape:

```ts
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";

type StreamData = {
  sender?: string;
  type?: string;
  call_cid?: string;
  created_by_display_name?: string;
};

function extractStreamData(
  notification: Notifications.Notification,
): StreamData | undefined {
  const raw = Platform.select({
    ios: (notification.request.trigger as any)?.payload,
    android: notification.request.content.data,
  }) as Record<string, unknown> | undefined;
  // iOS nests Stream data under `stream`; Android puts it flat on `data`.
  return (raw?.stream ?? raw) as StreamData | undefined;
}

function handleTap(stream: StreamData | undefined) {
  if (
    !stream ||
    stream.sender !== "stream.video" ||
    stream.type === "call.ring"
  )
    return;
  // stream.type, stream.call_cid, stream.created_by_display_name are available here.
  // Navigate to the call screen using your navigation method.
}

// Foreground / background taps
Notifications.addNotificationResponseReceivedListener((response) => {
  handleTap(extractStreamData(response.notification));
});

// Cold-start tap (app launched by tapping the banner from a killed state)
const lastResponse = Notifications.getLastNotificationResponse();
if (lastResponse) {
  handleTap(extractStreamData(lastResponse.notification));
}
```

</tabs-item>

</tabs>


---

This page was last updated at 2026-05-25T15:27:32.657Z.

For the most recent version of this documentation, visit [https://getstream.io/video/docs/react-native/incoming-calls/non-ringing-notifications-setup/handling-example/](https://getstream.io/video/docs/react-native/incoming-calls/non-ringing-notifications-setup/handling-example/).