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.

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.

Add push provider credentials to Stream

Follow these guides to add push providers:

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 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 — an FCM provider for Android, an APNs provider for iOS.

Install dependencies

npx expo install expo-notifications

Add the config plugin

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

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:

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().

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.

If you also use the ringing setup, 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.

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):

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,
    });
  });
};

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 + 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 to populate title/body from the stream payload before iOS renders it.

Step 3: Handle notification taps

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

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:

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

StreamVideoRN.onPushLogout() is only required if you have the ringing call setup 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.

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 onlyaddNotificationReceivedListener fires only in the foreground. For background display, add a Notification Service Extension (see note above).

See the Troubleshooting guide for more common issues.