Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Implement VoIP calling with CallKit and PushKit. Use when building incoming/outgoing call flows, registering for VoIP push notifications, configuring CXProvider and CXCallController, handling call actions, coordinating audio sessions, or creating Call Directory extensions for caller ID and call blocking.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 80% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 63% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 86% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 812% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 153% | 0% |
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.
| 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 |
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) } }
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() } } } }
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() } }
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)") } } }
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() } }
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.
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)") } } }
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 ) } } }
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.
| 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. |
CXProvider instance created at app launch and retainedCXProviderDelegate set before reporting any callsmustReport is true and may skip when falseapns-expiration of 0 or only a few secondsaction.fulfill() or action.fail() called for every provider delegate actionCXAnswerCallAction fulfilled only after the call server/media connection is readyprovider(_:didActivate:) callbackprovider(_:didDeactivate:) callback.playAndRecord with .voiceChat modedidUpdate pushCredentials callbackPKPushRegistry created at every app launch (not lazily)CXCallDirectoryPhoneNumber documented as country calling code + digitsCXCallDirectoryManager names status check, reload, and settings-opening APIsCXCallUpdate populated with localizedCallerName and remoteHandlestartedConnectingAt and connectedAt timestampsOther measured skills in the registry, with their headline benchmark lift.