How to add CallKit to React Native?
Use react-native-callkeep, which wraps native CallKit (iOS) and ConnectionService (Android) behind a single JavaScript API. Call its displayIncomingCall method whenever a VoIP push or SIP INVITE arrives.
How to handle VoIP push in React Native?
Use react-native-voip-push-notification to register for PushKit VoIP pushes on iOS, and forward the resulting event to react-native-callkeep's displayIncomingCall so the call is reported to the OS within seconds.
React Native VoIP App with CallKit: Production Guide 2026
react-native-callkeep and react-native-voip-push-notification are the React Native equivalents of Flutter's flutter_callkit_incoming — here's how to wire them up for a production VoIP app.
Beyond the core setup, this guide also covers how CallKeep fits into a full call flow, outgoing calls, centralized call-state management, backend push delivery, the edge cases that break naive implementations in production, App Store review considerations, debugging techniques, and how to actually test this on real devices rather than a simulator.
The Incoming Call Sequence
Why Native Incoming-Call Handling Isn't Optional
Both iOS and Android aggressively suspend backgrounded apps to conserve battery, and a React Native VoIP app that relies on a regular push notification or an in-app screen to signal an incoming call will simply miss calls once the app has been backgrounded for more than a few minutes. PushKit on iOS and a high-priority FCM message plus a foreground service on Android are the only reliable mechanisms for waking the app specifically to handle an incoming call, and CallKit/ConnectionService are what render that call as a real, native, full-screen experience rather than a dismissible notification banner the user might not even see in time.
Setup: react-native-callkeep + VoIP Push
npm install react-native-callkeep react-native-voip-push-notification
import RNCallKeep from 'react-native-callkeep';
import VoipPushNotification from 'react-native-voip-push-notification';
RNCallKeep.setup({
ios: { appName: 'MyVoipApp', supportsVideo: true },
android: {
alertTitle: 'Permissions required',
alertDescription: 'This app needs phone account permissions',
cancelButton: 'Cancel',
okButton: 'OK',
selfManaged: true,
},
});
VoipPushNotification.addEventListener('register', (token) => {
// Send token to your backend for VoIP push routing
});
VoipPushNotification.addEventListener('notification', (payload) => {
const { callId, callerName, handle } = payload;
RNCallKeep.displayIncomingCall(callId, handle, callerName, 'generic', true);
});Just as with Flutter, the VoIP Services certificate that sends this push is separate from a normal APNs certificate — generated in the Apple Developer portal under Certificates → VoIP Services.
Handling the Ringing-to-Answered Transition Correctly
A subtle but important detail: CallKit and ConnectionService both expect your app to explicitly acknowledge state transitions, not just react to them silently. When a call is answered, your app should call RNCallKeep.setCurrentCallActive(callUUID) once your SIP/WebRTC media path is actually connected and audio is flowing — not immediately on the answerCall event — so the native call UI accurately reflects "connecting" versus "connected" state to the user. Skipping this or calling it prematurely results in the native UI showing a connected call before audio has actually started, which reads as a broken call to the user even though the underlying logic eventually catches up.
Handling Answer/Decline Events
RNCallKeep.addEventListener('answerCall', ({ callUUID }) => {
// Bring app to foreground, start SIP/WebRTC call setup
});
RNCallKeep.addEventListener('endCall', ({ callUUID }) => {
// Send SIP CANCEL/BYE, clean up local call state
});How This Fits Into a Full Call Flow
react-native-callkeep and the VoIP push plugin solve exactly one problem — getting the phone to ring natively and waking your app to handle it. They carry no call media themselves. Once answerCall fires, your app is responsible for everything that makes the call actually work: registering a SIP client (PJSIP, Linphone), or opening a WebRTC/LiveKit connection, negotiating media, and starting the audio path. Teams new to CallKit integration sometimes assume the library "handles the call" once it's showing on screen — in reality, it's purely the incoming-call UI and OS wake mechanism, and the accept event is just the starting signal for your own call-setup logic.
Native Modules Behind react-native-callkeep
react-native-callkeep is a JavaScript bridge over real native platform APIs — on iOS it drives Apple's CXProvider and CXCallController directly, and on Android it drives the platform's ConnectionService and telecom framework. This is worth knowing for two practical reasons. First, when something behaves unexpectedly, the underlying native CallKit or ConnectionService documentation is often more useful than the JavaScript package's README, since the bridge is thin rather than a from-scratch reimplementation. Second, a native iOS or Android engineer on your team can meaningfully review and debug this integration even without deep React Native expertise, because the concepts map directly onto native APIs they likely already understand — this is a useful thing to know when a call-handling bug needs a second set of eyes and your React Native specialist isn't available. Treat the JavaScript API as a thin, well-documented veneer over platform code you can always drop down to directly if the bridge itself ever becomes the limiting factor.
Android Equivalent
react-native-callkeep's selfManaged: true Android config uses ConnectionService under the hood, driven by a high-priority Firebase Cloud Messaging data message rather than PushKit. The same displayIncomingCall and event-handling code above works unchanged on both platforms — the library abstracts the platform difference.
- Use FCM data messages (not notification messages) with high priority
- Declare a phoneCall-type foreground service in AndroidManifest.xml (required Android 14+)
- Request READ_PHONE_STATE and POST_NOTIFICATIONS (Android 13+) permissions at runtime
Handling Outgoing Calls
This guide focuses on incoming calls, but a complete VoIP app also needs outgoing calls to appear correctly in the native call UI and system call log. react-native-callkeep exposes startCall for exactly this — call it when the user initiates an outbound call from your app, before starting the actual SIP/WebRTC connection, so the native UI shows the outgoing call state (ringing, then connected) consistently with how incoming calls are presented. This also ensures your VoIP calls integrate properly with system-level features like the iOS/Android native call log and Bluetooth headset controls, which only work correctly when calls are registered through CallKit/ConnectionService rather than handled purely in your own in-app UI.
Backend Push Delivery
The client-side setup above is only useful once your backend can actually deliver a VoIP push when a call arrives. On an incoming SIP INVITE, your call-signalling server looks up the destination device's PushKit token, builds a properly formatted APNs VoIP push (with the apns-push-type header explicitly set to voip, not a standard alert push), and sends it via your VoIP push certificate. A mismatched push type is a common, hard-to-diagnose reason pushes silently fail to trigger CallKit at all — verify this header explicitly if calls aren't arriving despite everything on the client side looking correct.
Handling Edge Cases
- A call arrives before the VoIP push token has finished registering with your backend on first launch — queue or gracefully reject calls to devices that haven't completed registration rather than sending to a stale or missing token
- The user denies microphone or phone-account permissions — CallKit/ConnectionService will still display the incoming call, but your app must detect the permission gap on answer and show a clear in-call error rather than a silently dead audio channel
- A second call arrives while one is already active — decide explicitly whether to support call waiting (natively supported by both CallKit and ConnectionService) or reject the second call, since undefined behavior here looks like a bug to users
- The app was force-quit, not just backgrounded — PushKit still wakes the app for VoIP pushes on iOS in this state; Android's equivalent depends entirely on your foreground service being declared correctly, so test this specific scenario rather than assuming background and force-quit behave the same
App Store Review Considerations
Apple's review process specifically checks that PushKit's VoIP entitlement is used only for genuine incoming call notifications, not repurposed as a general background wake-up mechanism — this is a well-documented rejection reason. Make sure your review notes clearly explain the VoIP use case and that a reviewer can actually trigger and receive a real incoming call using your provided test account, since reviewers routinely attempt exactly this flow and will reject or delay apps where it can't be verified.
Debugging Common Issues
- Push arrives but CallKit never shows — add logging immediately in the push event handler to confirm it fires at all before debugging anything downstream
- CallKit shows the call but audio never connects — verify your SIP/WebRTC setup starts on the answerCall event specifically, not earlier; starting media setup on push receipt rather than on user acceptance is a common mistake
- Works in TestFlight but not production — check for an APNs sandbox-vs-production certificate mismatch on your backend, which is the most common cause of this exact symptom
- Intermittent missed calls in production only — confirm your backend retries failed APNs/FCM sends with backoff rather than dropping a failed push silently, since a missed VoIP push is a missed call with no other fallback
Testing on Real Devices
PushKit and CallKit cannot be meaningfully tested on the iOS Simulator — VoIP push delivery requires a real device with a real APNs token. Test at minimum: a call arriving in foreground, background, and fully force-quit states; a call arriving with the device locked; and delivery over both WiFi and cellular, since timing can differ between them. On Android, also test with Doze mode and battery optimization enabled, since some OEM Android skins apply background restrictions beyond stock Android and can delay or block your foreground service unless the app is explicitly exempted by the user. Build this test matrix into your release checklist rather than relying on ad-hoc manual testing before each release — the combinations are numerous enough that skipping any one of them is how regressions slip through.
Managing Call State Across the App
A common architecture mistake is scattering call-state logic (is there an active call, who's the caller, what's the call's current status) across multiple components that each listen to react-native-callkeep events independently. Centralize call state in a single store — Redux, Zustand, or React Context, whichever your app already uses — that subscribes to CallKeep events once at the app root and exposes a clean, derived call-state object to the rest of the UI. This matters more than it first appears: CallKit and ConnectionService events can arrive when your app is in almost any navigation state (a deep screen, a modal, mid-onboarding), and having a single source of truth for "is there a call right now, and what state is it in" makes it possible to correctly navigate to the in-call screen from anywhere in the app, rather than hoping whichever screen happens to be mounted has its own listener wired up correctly.
Summary
The core react-native-callkeep integration is a modest amount of well-documented code — the real engineering effort in a production React Native VoIP app goes into the edge cases (permissions denied, call waiting, force-quit state), correct backend push delivery, centralized call-state management, and testing across real devices and real network conditions. Get the happy path working first using the code above, then work through this guide's edge-case and testing sections methodically before shipping to real users — that's consistently where the gap lies between a working demo and a production-ready release.
FAQ
Is react-native-callkeep still maintained in 2026?
Yes — it remains the standard community library for this purpose in the React Native ecosystem, with active maintenance tracking new iOS/Android OS versions.
Do I need a separate library for Android push, or does FCM cover it directly?
Firebase Cloud Messaging (via @react-native-firebase/messaging) handles the Android push delivery; react-native-callkeep only handles rendering the native call UI once your app receives that push — you still wire the FCM listener yourself.
Does react-native-callkeep support call waiting?
Yes — both CallKit and ConnectionService natively support multiple simultaneous calls; react-native-callkeep exposes this through calling displayIncomingCall again for a second call, with your app logic deciding whether to allow it or respond busy.
How is this different from the Flutter equivalent (flutter_callkit_incoming)?
Conceptually identical — both wrap the same native CallKit and ConnectionService APIs behind a framework-specific interface. The underlying iOS/Android behavior, certificate requirements, and edge cases (permissions, call waiting, force-quit handling) are the same regardless of which cross-platform framework you're using.
What happens if my backend's push send fails silently?
The call is simply missed with no other notification, since VoIP pushes have no automatic fallback. Backend push-sending logic should retry with backoff and log failures distinctly, so missed-call patterns show up in monitoring rather than going unnoticed until a user complains.
Does react-native-callkeep handle outgoing calls too?
Yes — call startCall() when the user initiates an outbound call, before your SIP/WebRTC connection begins, so the native call UI and system call log reflect the outgoing call consistently with how incoming calls are handled.
Why does my app need to call setCurrentCallActive explicitly?
CallKit and ConnectionService both expect an explicit signal that media has actually connected, rather than inferring it from the answer event alone — calling it only once audio is flowing keeps the native UI's 'connecting' vs 'connected' state accurate, avoiding a call that looks connected before it actually is.
Should call state live in Redux or a simpler solution?
Either works — what matters is having exactly one source of truth for call state at the app root, subscribed to CallKeep events once, rather than each screen independently listening and potentially drifting out of sync with each other.
Related Reading
Building a React Native VoIP App?
CelloIP builds production React Native VoIP apps with full CallKit/ConnectionService integration.