An Expo module — written in Swift and Kotlin — that wraps CallKit on iOS and Jetpack Core-Telecom on Android with API parity. It owns the system call UI, the audio session, and VoIP push — your app owns the media (LiveKit, plain WebRTC, etc.).
The module is opinionated about system integration and unopinionated about media. You wire your media library to the events it emits.
📖 Full documentation: expo-callkit-telecom.mfairley.com
📱 See it in action
| Outgoing call | Incoming (banner) | Incoming (full screen) | |
|---|---|---|---|
| iOS |
|
![]() |
![]() |
| Android |
|
![]() |
![]() |
▶︎ Click an outgoing-call thumbnail above to watch the video. Or see the full demo on the docs site.
✨ Features
- 📱 Native calling UI — CallKit on iOS, Core-Telecom incoming-call notification + full-screen intent on Android
- 🔔 VoIP notifications — APNs VoIP on iOS (PushKit), FCM data messages on Android, parsed natively so calls can be reported from a terminated state
- 🎵 Ringtones — system ringtone for incoming calls, configurable via the config plugin
- ☎️ Dialtone — looped dialtone with fade-in for outgoing calls, configurable
- 🎧 Audio session management — cross-platform port types (
builtInReceiver,builtInSpeaker,headphones,bluetoothA2DP,bluetoothHFP,bluetoothLE,airPlay,hdmi,carAudio,usbAudio,lineOut) - 🔊 Speaker override and live route-change events
- 🎚️ Mute, hold, video, DTMF — both directions: app → system and system → app (e.g. native mute button → your media)
- 🗣️ Call intents on iOS — Recents list, Siri ("call Jane")
- 🧩 Typed TypeScript API with a single
CallSessionobject that tracks state across the call lifecycle
🧪 Verified against
Tested end-to-end on real devices via the runnable example/ app. Full compatibility matrix: Verified against.
📦 Install
bun add expo-callkit-telecom
Add the config plugin to app.json / app.config.ts. Minimal form:
With custom ringtone and dialtone:
Files in sounds are copied into the iOS bundle and Android raw resources at prebuild time. The full prop type is ExpoCallKitTelecomPluginProps in plugin/src/.
🧠 Concepts
The TS API is organised into three verbs:
| Verb | Direction | Examples |
|---|---|---|
| Request | App → System | startOutgoingCall, answerCall, endCall, setMuted |
| Report | App → System (state) | reportIncomingCall, reportOutgoingCallConnected, reportCallEnded |
| Fulfill | App → System (ack) | fulfillIncomingCallConnected |
Events flow the other way (System → App) via addXxxListener.
🚀 VoIP push payload
When the OS delivers a VoIP push (PushKit on iOS, an FCM data message on Android), the module parses the payload natively — before JS is running — and reports the call to the OS.
The event itself is always the same shape on both transports. All keys are camelCase:
Any keys you put under metadata are forwarded verbatim from the push payload all the way through to your JS event handler. The lib treats them as opaque — you cast at the read site:
Calls.addCallAnsweredListener(({ id }) => { const session = /* lookup */; const chatId = session?.incomingCallEvent?.metadata?.chatId as string | undefined; });
Both transports wrap the event under an incomingCall key, just at different layers — APNs in the push payload dictionary, FCM in the data block:
🍎 iOS — APNs VoIP push
Send a VoIP push (apns-push-type: voip) whose dictionary payload nests the event under incomingCall:
🤖 Android — FCM data message
FCM data values must be strings, so JSON-encode the inner event and put it under incomingCall:
Non-incomingCall data messages are forwarded to expo-notifications's service for normal handling.
🧪 Example
example/ contains a runnable Expo app (example/client/) and a zero-dep push-sender script (example/server/). See their READMEs for setup and how to validate VoIP push end-to-end.
🔑 Registering for VoIP push
import { registerVoIPPush, useVoIPPushToken, } from "expo-callkit-telecom"; // Once, early in app lifecycle: registerVoIPPush(); // In a React component: function App() { const voip = useVoIPPushToken(); useEffect(() => { if (voip) { // voip.type is "APNS_VOIP" on iOS, "FCM" on Android. sendToBackend(voip.token, voip.type); } }, [voip]); }
📚 API surface
See src/Calls.ts for full JSDoc. Main areas:
- Sessions —
getActiveCallSession,addCallSession{Added,Updated,Removed}Listener - Outgoing —
startOutgoingCall,addOutgoingCallStartedListener,reportOutgoingCallConnected - Incoming —
reportIncomingCall,addIncomingCallReportedListener,answerCall,addCallAnsweredListener,fulfillIncomingCallConnected,failIncomingCallConnected - End —
endCall,addCallEndedListener,reportCallEnded - Audio —
getAudioSession,setAudioSessionPortOverride,prepareAudioSessionForCall,addAudioRouteChangedListener - Mute / Hold / Video / DTMF —
setMuted,setHeld,reportVideo,playDTMFand their listeners - VoIP push —
registerVoIPPush,getVoIPPushToken,useVoIPPushToken,addVoIPPushTokenUpdatedListener
📝 Platform notes
- 🍎 iOS — requires the
voipbackground mode and a VoIP push certificate. Uses CallKit + PushKit + WebRTC'sRTCAudioSessionfor manual audio control. Min iOS 15.1. - 🤖 Android — requires
MANAGE_OWN_CALLSpermission, min SDK 26. Usesandroidx.core:core-telecom. Incoming calls come via FCM data messages — the config plugin registersExpoCallKitTelecomMessagingServiceautomatically. - 🎟️ 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.
🆚 Comparison with react-native-callkeep
react-native-callkeep is the long-standing React Native library in this space. expo-callkit-telecom solves the same problem but is built on the current generation of platform APIs: CallKit on iOS, Jetpack androidx.core:core-telecom on Android, Swift + Kotlin, the Expo Modules API with a config plugin, and RTCAudioSession coordination for manual-audio WebRTC stacks like LiveKit. It also parses APNs VoIP and FCM data payloads natively, so the cold-start incoming-call case works without app-side glue.
Full side-by-side, compatibility matrix, and migration notes: docs/vs-callkeep.md.





























