CallKit
Build VoIP calling features that integrate with the native iOS call UI using CallKit and PushKit. Covers incoming/outgoing call flows, VoIP push registration, audio session coordination, and call directory extensions.
Contents
- Setup
- Provider Configuration
- Incoming Call Flow
- Outgoing Call Flow
- PushKit VoIP Registration
- Audio Session Coordination
- Call Directory Extension and Manager
- Common Mistakes
- Review Checklist
- References
Setup
Project Configuration
- Enable the Voice over IP background mode in Signing & Capabilities
- Add the Push Notifications capability
- For call directory extensions, add a Call Directory Extension target
Key Types
| Type | Role |
|---|---|
CXProvider | Reports calls to the system, receives call actions |
CXCallController | Requests call actions (start, end, hold, mute) |
CXCallUpdate | Describes call metadata (caller name, video, handle) |
CXProviderDelegate | Handles system call actions and audio session events |
PKPushRegistry | Registers for and receives VoIP push notifications |
PKVoIPPushMetadata | iOS 26.4+ metadata that says whether a VoIP push must be reported |
Provider Configuration
Create a single CXProvider at app launch and keep it alive for the app
lifetime. Configure it with a CXProviderConfiguration that describes your
calling capabilities.
swiftimport CallKit /// CXProvider dispatches all delegate calls to the queue passed to `setDelegate(_:queue:)`. /// The `let` properties are initialized once and never mutated, making this type /// safe to share across concurrency domains despite @unchecked Sendable. final class CallManager: NSObject, @unchecked Sendable { static let shared = CallManager() let provider: CXProvider let callController = CXCallController() private override init() { let config = CXProviderConfiguration() config.localizedName = "My VoIP App" config.supportsVideo = true config.maximumCallsPerCallGroup = 1 config.maximumCallGroups = 2 config.supportedHandleTypes = [.phoneNumber, .emailAddress] config.includesCallsInRecents = true provider = CXProvider(configuration: config) super.init() provider.setDelegate(self, queue: nil) } }
Incoming Call Flow
When a required VoIP call push arrives, report the incoming call to CallKit immediately. The system displays the native call UI. You must report required calls before the PushKit completion handler returns -- failure to do so causes the system to terminate your app.
swiftfunc reportIncomingCall( uuid: UUID, handle: String, hasVideo: Bool ) async throws { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .phoneNumber, value: handle) update.hasVideo = hasVideo update.localizedCallerName = "Jane Doe" try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in provider.reportNewIncomingCall( with: uuid, update: update ) { error in if let error { continuation.resume(throwing: error) } else { continuation.resume() } } } }
Handling the Answer Action
Implement CXProviderDelegate to respond when the user answers:
swiftextension CallManager: CXProviderDelegate { func providerDidReset(_ provider: CXProvider) { // End all calls, reset audio } func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { // Prepare audio, then fulfill only after the call is actually ready configureAudioSession() connectToCallServer(callUUID: action.callUUID) { success in if success { action.fulfill() } else { provider.reportCall( with: action.callUUID, endedAt: Date(), reason: .failed ) action.fail() } } } func provider(_ provider: CXProvider, perform action: CXEndCallAction) { disconnectFromCallServer(callUUID: action.callUUID) action.fulfill() } }
Outgoing Call Flow
Use CXCallController to request an outgoing call. The system routes the
request through your CXProviderDelegate.
swiftfunc startOutgoingCall(handle: String, hasVideo: Bool) { let uuid = UUID() let handle = CXHandle(type: .phoneNumber, value: handle) let startAction = CXStartCallAction(call: uuid, handle: handle) startAction.isVideo = hasVideo let transaction = CXTransaction(action: startAction) callController.request(transaction) { error in if let error { print("Failed to start call: \(error)") } } }
Delegate Methods for Outgoing Calls
swiftextension CallManager { func provider(_ provider: CXProvider, perform action: CXStartCallAction) { configureAudioSession() // Begin connecting to server provider.reportOutgoingCall( with: action.callUUID, startedConnectingAt: Date() ) connectToServer(callUUID: action.callUUID) { provider.reportOutgoingCall( with: action.callUUID, connectedAt: Date() ) } action.fulfill() } }
PushKit VoIP Registration
Register for VoIP pushes at every app launch and send token changes to your
server. For iOS 13 SDK+ apps, every report-required VoIP call push must be
reported before PushKit completion using CallKit, or LiveCommunicationKit for
apps built on that framework. On iOS 26.4+, PKVoIPPushMetadata.mustReport is
the gate: true means report before completion; false means no CallKit or
LiveCommunicationKit report is required. Missing a required report before
completion can terminate the app, and repeated failures may stop VoIP delivery.
| Path | Report decision | Completion timing |
|---|---|---|
iOS 26.4+ mustReport == true | Report with CallKit or LiveCommunicationKit | After report callback |
iOS 26.4+ mustReport == false | No CallKit/LiveCommunicationKit report required | After local handling |
| Older delegate | iOS 13 SDK+ treats VoIP call pushes as report-required | After report callback |
swiftimport PushKit final class PushManager: NSObject, PKPushRegistryDelegate { let registry: PKPushRegistry override init() { registry = PKPushRegistry(queue: .main) super.init() registry.delegate = self registry.desiredPushTypes = [.voIP] } func pushRegistry( _ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType ) { let token = pushCredentials.token .map { String(format: "%02x", $0) } .joined() // Send token to your server sendTokenToServer(token) } @available(iOS 26.4, *) func pushRegistry( _ registry: PKPushRegistry, didReceiveIncomingVoIPPushWith payload: PKPushPayload, metadata: PKVoIPPushMetadata, withCompletionHandler completion: @escaping @Sendable () -> Void ) { guard metadata.mustReport else { completion() return } handleIncomingVoIPPush(payload, completion: completion) } // Keep the older callback for iOS 26.0-26.3 and older deployment targets. func pushRegistry( _ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void ) { guard type == .voIP else { completion() return } handleIncomingVoIPPush(payload, completion: completion) } private func handleIncomingVoIPPush( _ payload: PKPushPayload, completion: @escaping () -> Void ) { let callUUID = UUID() let handle = payload.dictionaryPayload["handle"] as? String ?? "Unknown" Task { do { try await CallManager.shared.reportIncomingCall( uuid: callUUID, handle: handle, hasVideo: false ) } catch { // Call was filtered by DND or block list } completion() } } }
Server-side VoIP pushes should use a short lifetime: set apns-expiration to
0 or only a few seconds. After the initial push wakes the app, send hangups
and call-detail changes over the app-server connection instead of sending more
VoIP pushes.
Audio Session Coordination
CallKit owns the audio activation boundary: start media only in
provider(_:didActivate:), and stop or tear it down in
provider(_:didDeactivate:) and reset paths.
swiftextension CallManager { func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { // Audio session is now active -- start audio engine / WebRTC startAudioEngine() } func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { // Audio session deactivated -- stop audio engine stopAudioEngine() } func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { try session.setCategory( .playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP] ) } catch { print("Audio session configuration failed: \(error)") } } }
Call Directory Extension and Manager
Use Call Directory for preloaded caller ID/blocking, not per-call API lookup.
The extension loads sorted bulk data in beginRequest(with:); the main app uses
CXCallDirectoryManager to check enabled status, open Call Blocking &
Identification settings when disabled, and reload after data changes. Store
CXCallDirectoryPhoneNumber as country code plus digits in ascending order
(for example 18005551234), not a formatted string.
swiftimport CallKit final class CallDirectoryHandler: CXCallDirectoryProvider { override func beginRequest( with context: CXCallDirectoryExtensionContext ) { if context.isIncremental { addOrRemoveIncrementalEntries(to: context) } else { addAllEntries(to: context) } context.completeRequest() } private func addAllEntries( to context: CXCallDirectoryExtensionContext ) { // Country code + digits, sorted in ascending order let blockedNumbers: [CXCallDirectoryPhoneNumber] = [ 18005551234, 18005555678 ] for number in blockedNumbers { context.addBlockingEntry( withNextSequentialPhoneNumber: number ) } let identifiedNumbers: [(CXCallDirectoryPhoneNumber, String)] = [ (18005551111, "Local Pizza"), (18005552222, "Dentist Office") ] for (number, label) in identifiedNumbers { context.addIdentificationEntry( withNextSequentialPhoneNumber: number, label: label ) } } }
Main-App Manager: Status, Settings, Reload
swiftlet manager = CXCallDirectoryManager.sharedInstance manager.getEnabledStatusForExtension(withIdentifier: extensionID) { status, _ in guard status == .enabled else { manager.openSettings { _ in } // Call Blocking & Identification return } manager.reloadExtension(withIdentifier: extensionID) { _ in } }
Check getEnabledStatusForExtension(...) before assuming the extension is
active, use openSettings(...) for Call Blocking & Identification when
disabled, and call reloadExtension(...) after data changes. Route APNs
auth-key rotation and normal remote-notification setup to push-notifications.
Common Mistakes
| Mistake | Fix |
|---|---|
| Required VoIP push is treated as data-only | Apply the version-specific report rule, report before completion, then invoke the PushKit completion handler. |
| Answer action is fulfilled before media/server readiness | Keep it pending while connecting; fulfill on readiness or fail and report .failed. |
Media starts before provider(_:didActivate:) | Prepare earlier if needed, but start only after activation and stop on deactivation/reset. |
An action path never calls fulfill() or fail() | Give success, cancellation, timeout, and network-error paths one terminal action. |
| Token refresh is ignored | Send every didUpdate pushCredentials token to the server. |
| Call Directory performs per-call networking | Preload sorted entries and reload the extension. |
Review Checklist
- VoIP background mode enabled in capabilities
- Single
CXProviderinstance created at app launch and retained -
CXProviderDelegateset before reporting any calls - iOS 26.4+ PushKit path reports when
mustReportis true and may skip when false - iOS 13 SDK+ PushKit VoIP call pushes report to CallKit before completion
- VoIP APNs requests use
apns-expirationof0or only a few seconds - Hangups and detail updates use the app-server connection after the initial push
-
action.fulfill()oraction.fail()called for every provider delegate action -
CXAnswerCallActionfulfilled only after the call server/media connection is ready - Audio engine started only after
provider(_:didActivate:)callback - Audio engine stopped in
provider(_:didDeactivate:)callback - Audio session category set to
.playAndRecordwith.voiceChatmode - VoIP push token sent to server on every
didUpdate pushCredentialscallback -
PKPushRegistrycreated at every app launch (not lazily) - Call Directory data is preloaded, not fetched per incoming call
-
CXCallDirectoryPhoneNumberdocumented as country calling code + digits -
CXCallDirectoryManagernames status check, reload, and settings-opening APIs -
CXCallUpdatepopulated withlocalizedCallerNameandremoteHandle - Outgoing calls report
startedConnectingAtandconnectedAttimestamps - iOS 26 call translation keeps upstream audio active during mute
- Encrypted metadata filtering mentions the notification service extension entitlement
References
- Extended patterns (hold, mute, group calls, delegate lifecycle): references/callkit-patterns.md
- CallKit framework
- CXProvider
- CXCallController
- CXCallAction
- CXCallUpdate
- CXProviderConfiguration
- CXProviderDelegate
- PKPushRegistry
- PKPushRegistryDelegate
- PKVoIPPushMetadata
- CXCallDirectoryProvider
- CXCallDirectoryPhoneNumber
- CXCallDirectoryManager
- CXSetTranslatingCallAction
- reportNewIncomingVoIPPushPayload(_:completion:)
- Making and receiving VoIP calls
- Responding to VoIP Notifications from PushKit


