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.).
π Full documentation: expo-callkit-telecom.mfairley.com
| 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.
- π± 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
- πΌοΈ Branded CallKit icon β show your app's icon on the iOS call screen via
iconTemplateIos - π§ 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
Tested end-to-end on real devices via the runnable example/ app. Full compatibility matrix: Verified against.
bun add expo-callkit-telecomYour app also needs expo-notifications and a react-native-webrtc build:
bun add @livekit/react-native-webrtc expo-notifications- A
react-native-webrtcbuild.@livekit/react-native-webrtc>= 144is the one the maintainers test against. The module doesn't depend on a specific WebRTC pod: on iOS it finds WebRTC's classes at runtime in whichever WebRTC framework your app ships,LK-prefixed (LiveKitWebRTC, from@livekit/react-native-webrtc144.2.0) or not, so upstreamreact-native-webrtc>= 124.0.5and other forks also work (community-verified). Install only one of them. expo-notificationsβ required on Android, where the module's FCM service builds on it.
Add the config plugin to app.json / app.config.ts. Minimal form:
With custom sounds and options:
{
"expo": {
"plugins": [
[
"expo-callkit-telecom",
{
"sounds": [
"./assets/sounds/ringtone.wav",
"./assets/sounds/dialtone.wav"
],
"defaultRingtoneIos": "ringtone.wav",
"defaultRingtoneAndroid": "ringtone.wav",
"defaultDialtone": "dialtone.wav",
"iconTemplateIos": "./assets/callkit-icon.png",
"notificationIconAndroid": "./assets/notification-icon.png",
"notificationStringsAndroid": {
"es": { "incomingCall": "Llamada entrante", "answer": "Contestar", "decline": "Rechazar" }
},
"incomingCallTimeout": 45,
"outgoingCallTimeout": 60,
"fulfillAnswerCallTimeout": 30,
"includesCallsInRecents": true,
"supportsVideo": true,
"microphonePermission": "$(PRODUCT_NAME) needs the microphone to make calls."
}
]
]
}
}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/.
iconTemplateIos is the app's icon on the iOS CallKit call screen (CXProviderConfiguration.iconTemplateImageData). CallKit renders it as a template β only the image's alpha channel is used (RGB is ignored) and the system tints it β so provide a ~40Γ40pt square PNG whose alpha describes the glyph. Android brands the incoming-call UI from the launcher icon automatically, so this option is iOS-only.
notificationIconAndroid is the small icon on the Android call notifications. Android draws it from the alpha channel only, so provide a white glyph on a transparent background (a ~96Γ96px PNG or a vector drawable .xml); without it, the launcher icon is used and an opaque icon renders as a solid shape.
notificationStringsAndroid sets the text on the Android call notifications, incoming-call screen, and notification channels, keyed by locale: default replaces the built-in English, and BCP 47 keys ("es", "pt-BR") are used when the device is set to that language. See AndroidNotificationStrings for the keys.
Set supportsVideo: false in audio-only apps to hide the video button on the iOS CallKit call screen.
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.
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:
// IncomingCallEvent (the "inner" event)
{
"eventId": "550e8400-e29b-41d4-a716-446655440000", // required (UUID), for dedup
"serverCallId": "9e7f...", // required β your backend's call id
// (distinct from CallSession.id,
// which is the OS-assigned UUID)
"hasVideo": false,
"startedAt": "2026-01-15T19:42:11.000Z", // RFC 3339, optional
"caller": {
"id": "<caller id>", // required β opaque, stable
"displayName": "Jane Smith",
"avatarUrl": "https://...",
"phoneNumber": "+14155551234", // optional; must be E.164 if present
"email": "jane@example.com"
},
"metadata": { // optional, opaque pass-through
"chatId": "abc-123",
"tenantId": "acme-co"
}
}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. (The snake_case incoming_call is also accepted for backwards compatibility.)
Send a VoIP push (apns-push-type: voip) whose dictionary payload nests the event under incomingCall:
{
"incomingCall": { /* IncomingCallEvent β see above */ }
}FCM data values must be strings, so JSON-encode the inner event and put it under incomingCall:
{
"data": {
"messageType": "incomingCall",
"incomingCall": "{\"eventId\":\"...\",\"serverCallId\":\"...\", ... }"
}
}The module also handles callEnded pushes for ending a call from the server (see platform notes); every call push carries a unique eventId, and repeat deliveries are dropped. Other data messages are forwarded to expo-notifications's service for normal handling.
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.
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]);
}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
- π iOS β requires the
voipbackground mode and a VoIP push certificate. Uses CallKit + PushKit + WebRTC'sRTCAudioSessionfor manual audio control. Min iOS 16.0. - π€ Android β requires
MANAGE_OWN_CALLSpermission, min SDK 26. Usesandroidx.core:core-telecom. Incoming calls come via FCM data messages β the config plugin registersExpoCallKitTelecomMessagingServiceautomatically. Firebase is optional: without agoogle-services.json, VoIP push registration is skipped with a warning and calls reported from JS still work. - ποΈ 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.
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.
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.




{ "expo": { "plugins": ["expo-callkit-telecom"] } }