Platform notes
iOS
- Requires the
voipbackground mode and a VoIP push certificate. - Uses CallKit + PushKit + WebRTC's
RTCAudioSessionfor manual audio control. - Minimum iOS version: 16.0.
Expo
- Supports Expo SDK 55 and later.
Android
- Requires
MANAGE_OWN_CALLSpermission. - Minimum SDK: 26 (Android 8.0).
- Uses
androidx.core:core-telecom. - Incoming calls come via FCM data messages — the config plugin registers
ExpoCallKitTelecomMessagingServiceautomatically.
Call ended while the app is killed
On a killed app, Android shows the incoming-call UI fully natively — no React context ever starts unless the user answers. That means a decline (or any call end) in that state cannot be delivered as a JS event: onCallEnded is intentionally excluded from the cold-start replay queue, so the event is dropped and the app never learns about it. If your backend needs to know (so the caller stops ringing immediately instead of waiting for a server timeout), register a manifest BroadcastReceiver for the package-internal broadcast the module fires in exactly that case.
Register the receiver via the config plugin, which writes the manifest entry. You supply the receiver class (e.g. in a local Expo module or your android/ source):
// app.config.js / app.json plugin props
[
"expo-callkit-telecom",
{ androidEventReceiver: ".CallEndedReceiver" },
]class CallEndedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.getStringExtra("eventName") != "onCallEnded") return
val payload = intent.getStringExtra("payload")?.let { JSONObject(it) } ?: return
val session = payload.optJSONObject("session")
val incomingCall = session?.optJSONObject("incomingCallEvent")
val serverCallId = incomingCall?.optString("serverCallId")
val metadata = incomingCall?.optJSONObject("metadata")
// e.g. POST a decline to your backend (use goAsync() for the network call)
}
}The broadcast carries two extras: eventName (the call event that couldn't reach JS) and payload, a JSON string of the exact body JS would have received. Events that can't reach a live JS observer and aren't queued for cold-start replay (e.g. onCallEnded, onCallReportedEnded) are broadcast — filter on eventName for the ones you care about (the example above handles only onCallEnded). For these terminal events the payload embeds the full session, so your backend ids (serverCallId) and any push metadata (auth tokens, routing ids) are available without app-side state. The broadcast fires only when the event would otherwise be lost; an alive app with a mounted listener keeps receiving the normal JS event, and events that flush to JS on the next launch are never broadcast (so there's no double-delivery).
A complete working setup ships in the example app: example/client/ sets androidEventReceiver in app.config.ts and copies plugins/CallEndedReceiver.kt into the generated android project — see "Testing system → app paths" in the example README for how to exercise it.
VoIP push token types
The VoIP push token type is reported as "APNS_VOIP" on iOS and "FCM" on Android — send both to your backend so it knows which transport to use.
Keeping connections alive in the background
This module hands the OS a CallKit/Core-Telecom call, which keeps the process alive during a call — but JS timers (setInterval, setTimeout) and JS-side network heartbeats are still subject to background throttling once the screen locks. If your media stack needs an app-level heartbeat (e.g. a WebSocket signalling channel) to survive the background, pair this module with react-native-nitro-keepalive-timer to get native timers that fire reliably while a call is active.