callkit

$npx mdskill add dpearson2699/swift-ios-skills/callkit

Integrate VoIP calling with native iOS call UI using CallKit and PushKit.

  • Enables VoIP calls to appear in the system call interface.
  • Depends on CallKit, PushKit, and VoIP push notification capabilities.
  • Configures CXProvider and CXCallController to handle call actions.
  • Reports call updates and coordinates audio sessions for seamless calling.

SKILL.md

.github/skills/callkitView on GitHub ↗
---
name: callkit
description: "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."
---

# 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](#setup)
- [Provider Configuration](#provider-configuration)
- [Incoming Call Flow](#incoming-call-flow)
- [Outgoing Call Flow](#outgoing-call-flow)
- [PushKit VoIP Registration](#pushkit-voip-registration)
- [Audio Session Coordination](#audio-session-coordination)
- [Call Directory Extension and Manager](#call-directory-extension-and-manager)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
- [References](#references)

## Setup

### Project Configuration

1. Enable the **Voice over IP** background mode in Signing & Capabilities
2. Add the **Push Notifications** capability
3. 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.

```swift
import 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.

```swift
func 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:

```swift
extension 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`.

```swift
func 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

```swift
extension 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 |

```swift
import 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.

```swift
extension 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.

```swift
import 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

```swift
let 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 `CXProvider` instance created at app launch and retained
- [ ] `CXProviderDelegate` set before reporting any calls
- [ ] iOS 26.4+ PushKit path reports when `mustReport` is true and may skip when false
- [ ] iOS 13 SDK+ PushKit VoIP call pushes report to CallKit before completion
- [ ] VoIP APNs requests use `apns-expiration` of `0` or only a few seconds
- [ ] Hangups and detail updates use the app-server connection after the initial push
- [ ] `action.fulfill()` or `action.fail()` called for every provider delegate action
- [ ] `CXAnswerCallAction` fulfilled 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 `.playAndRecord` with `.voiceChat` mode
- [ ] VoIP push token sent to server on every `didUpdate pushCredentials` callback
- [ ] `PKPushRegistry` created at every app launch (not lazily)
- [ ] Call Directory data is preloaded, not fetched per incoming call
- [ ] `CXCallDirectoryPhoneNumber` documented as country calling code + digits
- [ ] `CXCallDirectoryManager` names status check, reload, and settings-opening APIs
- [ ] `CXCallUpdate` populated with `localizedCallerName` and `remoteHandle`
- [ ] Outgoing calls report `startedConnectingAt` and `connectedAt` timestamps
- [ ] 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](references/callkit-patterns.md)
- [CallKit framework](https://sosumi.ai/documentation/callkit)
- [CXProvider](https://sosumi.ai/documentation/callkit/cxprovider)
- [CXCallController](https://sosumi.ai/documentation/callkit/cxcallcontroller)
- [CXCallAction](https://sosumi.ai/documentation/callkit/cxcallaction)
- [CXCallUpdate](https://sosumi.ai/documentation/callkit/cxcallupdate)
- [CXProviderConfiguration](https://sosumi.ai/documentation/callkit/cxproviderconfiguration)
- [CXProviderDelegate](https://sosumi.ai/documentation/callkit/cxproviderdelegate)
- [PKPushRegistry](https://sosumi.ai/documentation/pushkit/pkpushregistry)
- [PKPushRegistryDelegate](https://sosumi.ai/documentation/pushkit/pkpushregistrydelegate)
- [PKVoIPPushMetadata](https://sosumi.ai/documentation/pushkit/pkvoippushmetadata)
- [CXCallDirectoryProvider](https://sosumi.ai/documentation/callkit/cxcalldirectoryprovider)
- [CXCallDirectoryPhoneNumber](https://sosumi.ai/documentation/callkit/cxcalldirectoryphonenumber)
- [CXCallDirectoryManager](https://sosumi.ai/documentation/callkit/cxcalldirectorymanager)
- [CXSetTranslatingCallAction](https://sosumi.ai/documentation/callkit/cxsettranslatingcallaction)
- [reportNewIncomingVoIPPushPayload(_:completion:)](https://sosumi.ai/documentation/callkit/cxprovider/reportnewincomingvoippushpayload(_:completion:))
- [Making and receiving VoIP calls](https://sosumi.ai/documentation/callkit/making-and-receiving-voip-calls)
- [Responding to VoIP Notifications from PushKit](https://sosumi.ai/documentation/pushkit/responding-to-voip-notifications-from-pushkit)

More from dpearson2699/swift-ios-skills

SkillDescription
accessorysetupkitDiscover and configure Bluetooth and Wi-Fi accessories using AccessorySetupKit. Use when presenting a privacy-preserving accessory picker, defining discovery descriptors for BLE or Wi-Fi devices, handling accessory session events, migrating from CoreBluetooth permission-based scanning, or setting up accessories without requiring broad Bluetooth permissions.
activitykitImplement, review, or improve Live Activities and Dynamic Island experiences in iOS apps using ActivityKit. Use when building real-time updating widgets for the Lock Screen and Dynamic Island — delivery tracking, sports scores, ride-sharing status, workout timers, media playback, or any time-sensitive information that updates in real time. Also use when working with ActivityKit, ActivityAttributes, Activity lifecycle (request/update/end), Dynamic Island layouts (compact/minimal/expanded), push-to-update Live Activities, or Lock Screen live widgets.
adattributionkitMeasure ad effectiveness with privacy-preserving attribution using AdAttributionKit. Use when registering ad impressions, handling attribution postbacks, updating conversion values, implementing re-engagement attribution, configuring publisher or advertiser apps, or replacing SKAdNetwork with AdAttributionKit for ad measurement.
alarmkitImplement AlarmKit alarms and countdown timers for iOS and iPadOS with Lock Screen, Dynamic Island, StandBy, and paired Apple Watch system UI. Covers AlarmManager scheduling, AlarmAttributes and AlarmPresentation, system Stop and AlarmButton secondary actions, authorization, state observation, countdown widget-extension handoff, and Live Activity integration. Use when building wake-up alarms, countdown timers, or alarm-style alerts that need Apple's system alarm experience.
app-clipsBuild iOS App Clips with invocation URLs, App Clip Codes, NFC, QR codes, Safari banners, Maps, Messages, target setup, App Store Connect experiences, size/capability constraints, NSUserActivity routing, SKOverlay promotion, App Group/keychain handoff, ephemeral notifications, location confirmation, and full-app migration. Use when creating App Clips or wiring App Clip invocation, experience configuration, or full-app handoff.
app-intentsImplement App Intents for Siri, Shortcuts, Spotlight, widgets, Control Center, and Apple Intelligence on iOS. Covers AppIntent actions, AppEntity and EntityQuery models, AppShortcutsProvider phrases, IndexedEntity Spotlight indexing, WidgetConfigurationIntent, SnippetIntent, and assistant schemas. Use when exposing app actions or entities to system surfaces.
app-store-reviewAudits App Store submission readiness and rejection risk across current review guidelines, PrivacyInfo.xcprivacy and required-reason APIs, privacy labels, ATT, StoreKit payments, metadata, entitlements, widgets, and Live Activities. Use when preparing a submission, responding to rejection, reconciling privacy evidence, or separating upload blockers from cleanup.
apple-on-device-aiBuild private, on-device AI features on iPhone, iPad, and Mac with Foundation Models, Core ML, MLX Swift, or llama.cpp. Use when choosing an Apple-local model runtime, building an Apple Intelligence chatbot or tool-calling feature, running an LLM on Apple Silicon, converting or compressing a Python model for Core ML, or comparing on-device inference backends. For Swift Core ML loading and prediction code, use the coreml skill.
appmigrationkitTransfer app data to or from other platforms using AppMigrationKit. Use when implementing system-orchestrated one-time migration between iOS and Android or another platform, building an AppMigrationExtension, packaging transportable resources with ResourcesArchiver, importing resources on the destination device, reporting import progress, handling migration errors and app group cleanup, checking MigrationStatus, or testing migration code with AppMigrationTester.
audioaccessorykitSupport automatic audio switching for paired third-party Bluetooth headphones or earbuds with AudioAccessoryKit. Use when a companion app registers an audio accessory, an app extension reports worn/removed placement or connected source-device changes, or AccessoryControlDevice capabilities and errors need handling. Do not use for general AVAudioSession routing, Bluetooth transport, or initial accessory pairing.