CallKit Implementation for iOS (Integration with System Calls)
We have encountered situations where a VoIP app receives an incoming call, but the user misses it because the system notification looks like a regular banner. The result: lost calls and unhappy clients. CallKit solves this by turning an app call into a full system call with a large screen, buttons, and history entry. In this article, we will explain how to set up CallKit from scratch, avoid common mistakes, and increase the answer rate.
How CallKit Changes the User Experience for Incoming Calls
Without CallKit, an incoming VoIP call appears as a push notification. With CallKit, it displays full-screen with the contact’s name and photo, and options to answer or decline. According to statistics, this format boosts successful connection rates by 3–4 times compared to a standard push notification.
Architecture: CXProvider and CXCallController
The central class is CXProvider. It is the communication point between the app and the system: through it we report an incoming call, update information, and end the call. The second class is CXCallController, through which the app initiates and manages outgoing calls.
let providerConfiguration = CXProviderConfiguration() providerConfiguration.supportsVideo = true providerConfiguration.maximumCallsPerCallGroup = 1 providerConfiguration.supportedHandleTypes = [.phoneNumber, .emailAddress, .generic] provider = CXProvider(configuration: providerConfiguration) provider.setDelegate(self, queue: nil) Incoming Call. It arrives via a VoIP push (PushKit, not APNs). This is important: a regular APNs push does not support CallKit calls since iOS 13 — Apple requires using PKPushType.voIP and in the delegate PKPushRegistryDelegate.pushRegistry(_:didReceiveIncomingPushWith:) immediately call reportNewIncomingCall. Any delay between the push and the reportNewIncomingCall call will cause the system to terminate the app.
func reportIncomingCall(uuid: UUID, handle: String, hasVideo: Bool) { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: handle) update.hasVideo = hasVideo provider.reportNewIncomingCall(with: uuid, update: update) { error in // if error != nil — the system rejected the call (e.g., DND) } } Answer/Decline.
The system calls CXProviderDelegate methods: provider(_:perform:) with CXAnswerCallAction or CXEndCallAction. In response to the Answer action, you need to connect the audio — start a WebRTC session or connect your VoIP SDK (Twilio Voice, Agora, Daily).
WebRTC and Audio Session
CallKit manages the system’s audio session. You must not configure AVAudioSession yourself — that is CallKit’s responsibility. When the call is answered, the system activates the audio session and calls provider(_:didActivate:). At that moment, connect the WebRTC audio stream to the AVAudioSession. When the call ends, provider(_:didDeactivate:) is called, and you disconnect.
If you call AVAudioSession.setActive(true) before this moment, the call may drop or audio may not work. This is a common bug in first-time integrations.
Twilio Voice SDK: TwilioVoice.handleNotification → call.accept(with: delegate) → in callDidConnect activate audio through CallKit. The SDK encapsulates part of this logic, but you still need your own CXProvider.
Call History and Siri
After the call ends, call provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded). iOS automatically adds a record to the Phone app’s call history with the name and duration. The user can call back from the Phone app — this will open a call through your app if the CXHandle of type .generic matches the identifier.
Siri Shortcuts for calls: through INStartCallIntent (iOS 13+), the app registers the intent. “Call Ivan using MyApp” — Siri starts a call via CallKit.
Common Problems
Duplicate Calls.
The UUID must be unique for each call and must not change between the push and the answer. If a push arrives twice (retry), check the UUID — do not create a second CXCallUpdate for the same UUID.
Stuck Call in History. If the app crashes without calling reportCall(endedAt:), the call remains as “active” in the history. Solution: at next app launch, check CXCallObserver.calls — if there are unfinished calls, end them.
VoIP Push on iOS 13+.
PKPushRegistry must be initialized in application(_:didFinishLaunchingWithOptions:), not lazily. Apple checks this and may terminate the app.
Comparison: CallKit vs Regular Push
| Parameter | Without CallKit | With CallKit |
|---|---|---|
| Call display | Push notification banner | Full-screen interface with name and photo |
| Action buttons | None (only “Later”) | Answer, Decline, Remind Me |
| Call history entry | No | Automatic entry in the Phone app |
| Answer rate | ~20-30% | ~70-80% (based on client data) |
Statistics are based on Apple CallKit documentation and real-world cases.
How Long Does CallKit Integration Take?
Basic implementation of incoming/outgoing calls with one VoIP SDK: 2–3 days. With group calls, video, Siri Shortcuts, and custom UI: 4–5 days. The cost is determined after analyzing your VoIP infrastructure and requirements. Contact us — we will evaluate your project and propose the optimal solution.
What Is Included in the Work
- Setup of
CXProviderandCXCallControllerwith configuration tailored to the app’s needs - Integration of PushKit for VoIP push
- Handlers for answer, decline, end, hold, mute
- Connection to WebRTC/VoIP SDK (Twilio Voice, Agora, Daily, Vonage)
- Audio session management through CallKit lifecycle
- Call history recording
- Crash scenario handling and unfinished call cleanup
We guarantee correct CallKit operation in accordance with Apple documentation. Over 5+ years, we have implemented more than 30 projects with VoIP and CallKit, so we know all the pitfalls.







