npx expo install expo-notificationsExpo
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:
- Android - Firebase Cloud Messaging
- iOS - Apple Push Notification Service (APNs)
Prerequisites
Ensure you have:
- FCM credentials set up for Android — a
google-services.jsonfile placed in your project root and referenced fromapp.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. - Push providers registered on the Stream Dashboard — an FCM provider for Android, an APNs provider for iOS.
Install dependencies
Add the config plugin
Add expo-notifications and enable non-ringing push notifications in 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:
{
"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().
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):
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:
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:
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(). Ensureexpo-notificationsis listed inapp.jsonplugins and you rannpx expo prebuild --cleanafter adding it. - No notifications on Android — Verify
google-services.jsonis present and referenced fromapp.jsonand that an FCM provider is configured in the Stream Dashboard. Checkclient.listDevices()to confirm the FCM token was registered. - Duplicate notifications on Android — If Stream sends a push with both
notificationanddatapayloads, Android's FCM service auto-displays the banner while youraddNotificationReceivedListeneralso fires. Either configure Stream to send data-only pushes or skip yourscheduleNotificationAsynccall when a system banner is already shown. - iOS foreground only —
addNotificationReceivedListenerfires only in the foreground. For background display, add a Notification Service Extension (see note above).
See the Troubleshooting guide for more common issues.