Native incoming calls in React Native: CallKit on iOS, full-screen calls on Android
A calling app has to ring when it is not running: on a locked iPhone, on an Android phone in a pocket, after the system has reclaimed the process. That takes a push that wakes the app and a system incoming-call screen, on each platform in its own way. This guide shows how the Streaming CDN calling SDK does it for React Native and Expo.
How a call reaches a sleeping phone
- The caller starts a call; the call service sends a push with the call id, caller and mode (audio or video).
- iOS: a VoIP push (PushKit) wakes the app, which reports the call to CallKit. Android: an FCM message wakes the app, which shows a full-screen incoming-call notification.
- The person answers from the lock screen. The app accepts the call with its client token.
- The service makes the accept atomic — the first device to answer wins — and returns the media credential to that device only. Every other device signed in as the same person stops ringing.
The push itself carries no media token and no server key; the media credential is returned only after the accept succeeds.
{
"callId": "rtc_call_...",
"callerId": "user-17",
"callerName": "Alex",
"mode": "video",
"expiresAt": "2026-07-26T04:00:00Z"
}
Install
npm install @streaming-cdn/rtc-react-native react-native-webrtc
- Bare React Native apps rebuild the native project as usual.
- Expo apps need
expo prebuildand a development build or EAS Build — Expo Go cannot load a native WebRTC runtime. Add@streaming-cdn/rtc-react-nativeto the Expopluginslist so the camera and microphone usage descriptions are generated. - Upgrading from a version without the native call layer means rebuilding the native project; until then native calls report that the module is unavailable.
Join a call
Your backend keeps the server API key and hands the app a short-lived credential:
import { ReactNativeRtcClient } from "@streaming-cdn/rtc-react-native";
const identity = await fetch("https://your-backend.example.com/api/rtc/identity", {
credentials: "include"
}).then((response) => response.json());
const rtc = new ReactNativeRtcClient({
credential: identity.credential,
mode: "video",
publishAudio: true,
publishVideo: true,
receiveAudio: true,
receiveVideo: true
});
await rtc.join();
What the native call layer gives you
| Capability | API | Platform behaviour |
|---|---|---|
| Locked-screen and background calls | RtcNativeIncomingCalls | Android full-screen intent notification; iOS PushKit with CallKit |
| Ringtone and ringback | RtcCallTones | Honours the Android ringer mode; on iOS CallKit owns the incoming ringtone |
| Audio output routing | client.setAudioOutput(), RtcAudioRouting | Earpiece, speaker and Bluetooth through Android AudioManager and iOS AVAudioSession |
On iOS a call identifier must be a UUID. On Android your app owns its Firebase registration and forwards payloads to the SDK, so Firebase stays an optional dependency.
The Android 14 trap: full-screen intent
USE_FULL_SCREEN_INTENT is what lets an incoming call take over the screen. Three facts to know before shipping:
- Since Android 14 (API 34) it is a special app access, granted automatically only to apps whose core function is alarms or calls. Other apps start without it.
- Since 31 May 2024, Google Play requires a declaration for it in the Play Console (App content → Sensitive permissions). Submit it before review.
- When the grant is missing there is no error: the call silently becomes an ordinary heads-up notification that is easy to miss. A build can pass testing and still be effectively uncallable on real devices.
Check at runtime and guide the user before the first call:
const caps = await RtcNativeIncomingCalls.getIncomingCallCapabilities();
// caps.presentation: "full-screen" | "notification" | "none"
if (caps.presentation !== "full-screen") {
// explain why, then:
await RtcNativeIncomingCalls.openFullScreenIntentSettings();
}
getIncomingCallCapabilities() also reports notificationsEnabled; when it is false the call will not appear at all. On iOS the result is always full-screen, because CallKit owns presentation.
Ringing ends on its own
A ringing invitation is valid for 45 seconds and a call nobody answers is closed as missed. On Android the incoming-call notification also carries a system timeout (45 seconds by default, ringTimeoutMs in the push payload, 10–120 seconds), so ringing stops even if the app process was reclaimed and no cancel can run.
Errors worth handling
| Response | Meaning | What to do |
|---|---|---|
401 | The client identity token expired | Refresh it through your backend |
403 | This identity is not a recipient of the call | Do not show the call |
409 | Another device answered, or the call stopped ringing | End the incoming UI quietly |
Push after expiresAt | The call is over | Discard it without showing any UI |