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.














