# Expo

Set up push notifications for non-ringing events (missed calls, livestream started, etc.) in your Expo app.

Non-ringing notifications are **not handled by the SDK**. The Stream backend sends push notifications directly to the device via FCM (Android) or APNs (iOS). Obtaining the device token, displaying the notification, and handling taps is entirely your app's responsibility. You can use any notification library or custom native code.

<admonition type="info">

The examples below use `expo-notifications` for everything — token acquisition, display, and tap handling. Its `getDevicePushTokenAsync()` returns the raw FCM registration token on Android and the APNs device token on iOS, so you can drive both platforms with a single API and without pulling in any additional native push libraries.

</admonition>

## Add push provider credentials to Stream

Follow these guides to add push providers:

- **Android** - [Firebase Cloud Messaging](/video/docs/react-native/incoming-calls/push-providers/firebase/)
- **iOS** - [Apple Push Notification Service (APNs)](/video/docs/react-native/incoming-calls/push-providers/apn-voip/)


## Prerequisites

Ensure you have:

1. **FCM credentials set up for Android** — a `google-services.json` file placed in your project root and referenced from `app.json` (`expo.android.googleServicesFile`). See the [ringing setup guide](/video/docs/react-native/incoming-calls/ringing-setup/expo/) if you need detailed steps for creating the Google Cloud / FCM project and uploading its service account JSON to Stream.
2. **Push providers registered** on the [Stream Dashboard](https://dashboard.getstream.io/) — an FCM provider for Android, an APNs provider for iOS.

## Install dependencies

```bash title=Terminal
npx expo install expo-notifications
```

## Add the config plugin

Add `expo-notifications` and enable non-ringing push notifications in `app.json`:

```json title="app.json"
{
  "plugins": [
    [
      "@stream-io/video-react-native-sdk",
      {
        "enableNonRingingPushNotifications": true
      }
    ],
    [
      "expo-notifications",
      {
        "icon": "./assets/images/icon.png"
      }
    ]
  ]
}
```

For iOS with Expo SDK 51+, add the notifications entitlement:

```json title="app.json"
{
  "expo": {
    "ios": {
      "entitlements": {
        "aps-environment": "production"
      }
    }
  }
}
```

Run `npx expo prebuild --clean` after modifying config plugins.

## Step 1: Register the device token

The Stream backend needs your device's push token to deliver non-ringing notifications. `expo-notifications` returns the native token on both platforms — an FCM registration token on Android and an APNs device token on iOS — which you register via `client.addDevice()`.

```tsx title="hooks/useRegisterNonRingingPushToken.ts"
import { useEffect, useRef } from "react";
import { useStreamVideoClient } from "@stream-io/video-react-native-sdk";
import * as Notifications from "expo-notifications";

const IOS_PROVIDER_NAME = "your-apn-provider-name"; // must match Stream Dashboard
const ANDROID_PROVIDER_NAME = "your-fcm-provider-name"; // must match Stream Dashboard

export const useRegisterNonRingingPushToken = () => {
  const client = useStreamVideoClient();
  const lastToken = useRef("");

  useEffect(() => {
    if (!client) return;

    const register = async (token: string, type: "ios" | "android") => {
      if (token === lastToken.current) return;
      const pushProvider = type === "ios" ? "apn" : "firebase";
      const providerName =
        type === "ios" ? IOS_PROVIDER_NAME : ANDROID_PROVIDER_NAME;
      await client.addDevice(token, pushProvider, providerName);
      lastToken.current = token;
    };

    const setup = async () => {
      const { status } = await Notifications.requestPermissionsAsync();
      if (status !== "granted") return;

      const { type, data } = await Notifications.getDevicePushTokenAsync();
      if (data) await register(data as string, type);
    };

    setup();

    const subscription = Notifications.addPushTokenListener(
      ({ type, data }) => {
        if (data) register(data as string, type);
      },
    );

    return () => {
      subscription.remove();
      if (lastToken.current) {
        client.removeDevice(lastToken.current).catch(() => {});
      }
    };
  }, [client]);
};
```

Call this hook inside your `<StreamVideo>` provider so it has access to the client.

<admonition type="note">

If you also use the [ringing setup](/video/docs/react-native/incoming-calls/ringing-setup/expo/), the SDK already registers the FCM token on Android internally as part of the ringing flow. Calling `client.addDevice` again with the same token is idempotent — the backend upserts the device — so the hook above is safe to run regardless. If you want to avoid the redundant call, wrap the Android branch in a `Platform.OS` check.

</admonition>

## Step 2: Handle incoming push and display a notification

When a non-ringing push arrives, parse the payload and display a local notification via `expo-notifications`. A single listener works for both platforms — you only need to account for the fact that Stream wraps the payload differently on iOS (nested under a `stream` key) versus Android (flat `data`):

```ts title="utils/setNotificationListeners.ts"
import * as Notifications from "expo-notifications";

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

const extractStreamData = (
  notification: Notifications.Notification,
): StreamData | null => {
  const raw = notification.request.content.data as
    | Record<string, unknown>
    | undefined;
  // iOS nests Stream data under `stream`; Android puts it flat on `data`.
  const stream = (raw?.stream ?? raw) as StreamData | undefined;
  if (stream?.sender !== "stream.video" || stream.type === "call.ring") {
    return null;
  }
  return stream;
};

export const setNotificationListeners = () => {
  // Suppress the empty native banner that Stream sends for non-ringing APNs pushes;
  // we display our own local notification below. Pass-through for anything else.
  Notifications.setNotificationHandler({
    handleNotification: async (notification) => {
      if (extractStreamData(notification)) {
        return {
          shouldShowBanner: false,
          shouldShowList: false,
          shouldShowAlert: false,
          shouldPlaySound: false,
          shouldSetBadge: false,
        };
      }
      return {
        shouldShowBanner: true,
        shouldShowList: false,
        shouldShowAlert: true,
        shouldPlaySound: true,
        shouldSetBadge: false,
      };
    },
  });

  Notifications.addNotificationReceivedListener((notification) => {
    const stream = extractStreamData(notification);
    if (!stream) return;
    Notifications.scheduleNotificationAsync({
      content: {
        title: "Call notification",
        body: `${stream.created_by_display_name ?? "Someone"} is notifying you about a call`,
        data: {
          sender: "stream.video",
          type: stream.type,
          call_cid: stream.call_cid,
        },
      },
      trigger: null,
    });
  });
};
```

<admonition type="note">

**Android background / killed state.** `addNotificationReceivedListener` fires only while the app's JS engine is alive. If Stream sends an FCM message with a `notification` payload, Android's FCM service displays it automatically without any JS running — that covers background and killed state. If Stream sends data-only FCM messages you want to render yourself in killed state, register a background task with [`TaskManager.defineTask`](https://docs.expo.dev/versions/latest/sdk/task-manager/) + `Notifications.registerTaskAsync`.

**iOS background / killed state.** `addNotificationReceivedListener` fires only in the foreground. Because Stream sends empty `aps.alert`, the system would otherwise display a blank banner in background. Add a [Notification Service Extension](https://developer.apple.com/documentation/usernotifications/modifying_content_in_newly_delivered_notifications) to populate `title`/`body` from the `stream` payload before iOS renders it.

</admonition>

## Step 3: Handle notification taps

When the user taps a non-ringing notification, navigate them to the relevant screen:

```ts title="utils/registerNonRingingNotifications.ts"
import * as Notifications from "expo-notifications";
import { router } from "expo-router";

export const registerNonRingingNotificationHandler = () => {
  // Handle taps when app is running
  Notifications.addNotificationResponseReceivedListener((response) => {
    const data = response.notification.request.content.data;
    if (data?.sender === "stream.video" && data?.call_cid) {
      router.push("/meeting");
    }
  });

  // Handle cold-start tap (app was killed, launched by tapping the notification)
  const lastResponse = Notifications.getLastNotificationResponse();
  if (lastResponse?.notification.request.content.data?.call_cid) {
    router.push("/meeting");
  }
};
```

## Step 4: Initialize at app startup

Call your notification setup outside the React lifecycle so it's ready when the app opens from a push:

```js title="index.js"
import "expo-router/entry";
import { setNotificationListeners } from "src/utils/setNotificationListeners";
import { registerNonRingingNotificationHandler } from "src/utils/registerNonRingingNotifications";

setNotificationListeners();
registerNonRingingNotificationHandler();
```

## Disabling push — usually on logout

<admonition type="note">

`StreamVideoRN.onPushLogout()` is only required if you have the [ringing call setup](/video/docs/react-native/incoming-calls/ringing-setup/expo/) enabled — it cleans up the push token registered by the SDK for ringing. If you only use non-ringing notifications, remove the device token yourself via `client.removeDevice(token)` on logout.

</admonition>

```js
import { StreamVideoRN } from "@stream-io/video-react-native-sdk";

await StreamVideoRN.onPushLogout();
```

## Troubleshooting

- **No notifications on iOS** — Verify the APNs token is registered with Stream via `client.listDevices()`. Ensure `expo-notifications` is listed in `app.json` plugins and you ran `npx expo prebuild --clean` after adding it.
- **No notifications on Android** — Verify `google-services.json` is present and referenced from `app.json` and that an FCM provider is configured in the Stream Dashboard. Check `client.listDevices()` to confirm the FCM token was registered.
- **Duplicate notifications on Android** — If Stream sends a push with both `notification` and `data` payloads, Android's FCM service auto-displays the banner while your `addNotificationReceivedListener` also fires. Either configure Stream to send data-only pushes or skip your `scheduleNotificationAsync` call when a system banner is already shown.
- **iOS foreground only** — `addNotificationReceivedListener` fires only in the foreground. For background display, add a Notification Service Extension (see note above).

See the [Troubleshooting guide](/video/docs/react-native/advanced/troubleshooting/) for more common issues.


---

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

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