Skip to content

Platform notes ​

iOS ​

  • Requires the voip background mode and a VoIP push certificate.
  • Uses CallKit + PushKit + WebRTC's RTCAudioSession for manual audio control.
  • Minimum iOS version: 16.0.

Expo ​

  • Supports Expo SDK 55 and later.

Android ​

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

js
// app.config.js / app.json plugin props
[
  "expo-callkit-telecom",
  { androidEventReceiver: ".CallEndedReceiver" },
]
kotlin
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.