
Callkit
- 2.1k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
callkit is an agent skill that Implement VoIP calling with CallKit and PushKit. Use when building incoming/outgoing call flows, registering for VoIP push notifications, configuring CXProvider and CXCal.
About
The callkit skill. 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. Covers incoming/outgoing call flows, VoIP push registration, audio session coordination, and call directory extensions. Enable the **Voice over IP** background mode in Signing & Capabilities 2. Add the **Push Notifications** capability 3. Configure it with a that describes your calling capabilities. 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. The workflow follows the source SKILL.md contract with progressive reference loading, clear trigger phrases, and practical steps developers can apply directly in agent sessions.
- [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)
Callkit by the numbers
- 2,066 all-time installs (skills.sh)
- +108 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #106 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
callkit capabilities & compatibility
- Capabilities
- [provider configuration](#provider configuration · [incoming call flow](#incoming call flow) · [outgoing call flow](#outgoing call flow) · [pushkit voip registration](#pushkit voip regist · [audio session coordination](#audio session coor
- Use cases
- frontend · ui design · api development
What callkit says it does
Covers incoming/outgoing call flows, VoIP push registration, audio session coordination, and call directory extensions.
Enable the **Voice over IP** background mode in Signing & Capabilities 2.
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill callkitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.1k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I apply callkit correctly using the SKILL.md workflows and reference files?
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 act
Who is it for?
Developers and software engineers working with callkit patterns from the skill documentation.
Skip if: Skip when cached docs are empty, boilerplate-only, or outside the skill documented scope.
When should I use this skill?
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 a
What you get
Grounded callkit guidance with highlights, triggers, and evidence quotes from SKILL.md.
- CallKit and PushKit implementation outline with delegate and APNs notes
Files
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. Targets Swift 6.3 / iOS 26+.
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
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.
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.
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:
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.
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
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 |
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 manages audio session activation/deactivation. Configure your audio session when CallKit tells you to, not before. Review answers should name both sides: start media only in provider(_:didActivate:), and stop/tear down media in provider(_:didDeactivate:) or reset paths.
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.
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
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
DON'T: Fail to report a required call on VoIP push receipt
Follow the PushKit report rules above: iOS 13 SDK+ apps must report report-required VoIP call pushes before completion, and on iOS 26.4+ PKVoIPPushMetadata.mustReport identifies which pushes are required. Missing a required report can terminate the app; repeated failures may stop VoIP delivery.
Do not treat a required VoIP push as a data-only notification. Report the call to CallKit and call the PushKit completion handler from the report completion.
DON'T: Fulfill answer before the call is connected
When the user answers before your app has established the server/media connection, leave the CXAnswerCallAction pending while connecting. Fulfill it after the call is ready; if connection fails, fail the action and report the call ended with .failed.
DON'T: Start audio before CallKit activates the session
Starting your audio engine before provider(_:didActivate:) causes silence or immediate deactivation. CallKit manages session priority with the system.
Prepare audio in the answer/start action if needed, then start media only from provider(_:didActivate:).
For iOS 26 call translation, set CXProviderConfiguration.supportsAudioTranslation when your service supports it and handle CXSetTranslatingCallAction. If a person mutes during a translated call, mute app input with CXSetMutedCallAction; do not deactivate upstream audio that translated audio depends on.
For encrypted VoIP metadata, use CXProvider.reportNewIncomingVoIPPushPayload only from a notification service extension when the server cannot determine whether encrypted content is a VoIP call or other data. That path requires the com.apple.developer.usernotifications.filtering entitlement; otherwise send a normal PushKit VoIP push.
DON'T: Forget to call action.fulfill() or action.fail()
Failing to fulfill or fail an action leaves the call in a limbo state and triggers the timeout handler.
Every provider action path must eventually call fulfill() or fail(), including network-error and cancellation paths.
DON'T: Ignore push token refresh
The VoIP push token can change at any time. If your server has a stale token, pushes silently fail and incoming calls never arrive.
Send the token to your server every time didUpdate pushCredentials fires, not just during first-run onboarding.
DON'T: Use Call Directory for per-call lookup
Call Directory extensions provide preloaded caller ID and blocking data. They cannot ask a web service for the incoming caller during call presentation. Fetch or generate the dataset ahead of time, reload the extension, and add entries in sorted sequential order.
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
{
"skill_name": "callkit",
"evals": [
{
"id": 0,
"prompt": "I'm updating an iOS 26 VoIP app for the latest PushKit behavior. Sketch the PushKit + CallKit incoming-call path for iOS 26.4 and earlier iOS 26.x, including when I can ignore a VoIP push, when I must report to CallKit, and the APNs server settings that matter.",
"expected_output": "A source-grounded CallKit/PushKit implementation outline that prefers the iOS 26.4 metadata delegate, respects mustReport, keeps the older delegate as fallback, reports required calls before PushKit completion, and notes short VoIP push expiration.",
"files": [],
"expectations": [
"Uses the iOS 26.4+ PKPushRegistryDelegate method with PKVoIPPushMetadata when available.",
"Checks PKVoIPPushMetadata.mustReport and explains that false means the app is not required to report the push to CallKit or LiveCommunicationKit.",
"Keeps the older pushRegistry(_:didReceiveIncomingPushWith:for:completion:) path for iOS 26.0-26.3 or older deployment targets.",
"Reports required VoIP call pushes with CXProvider.reportNewIncomingCall before completing PushKit handling.",
"Mentions that apps built with the iOS 13 SDK or later must use CallKit for PushKit VoIP calls and repeated report failures can stop VoIP push delivery.",
"Recommends apns-expiration 0 or only a few seconds for VoIP push requests."
]
},
{
"id": 1,
"prompt": "Review this CallKit plan for mistakes: when the user taps Answer, call action.fulfill() right away and then connect WebRTC; start the audio engine before provider(_:didActivate:); send another VoIP push if the caller hangs up; for encrypted payloads, always use CXProvider.reportNewIncomingVoIPPushPayload from a notification service extension.",
"expected_output": "A correction-focused review that defers answer fulfillment until connection succeeds, waits for CallKit audio activation, uses the app-server connection after the initial push, and limits reportNewIncomingVoIPPushPayload to Apple's documented encrypted-metadata case.",
"files": [],
"expectations": [
"Explains that CXAnswerCallAction should be fulfilled after the server/media connection is established and failed or ended if connection fails.",
"States that the audio engine should start only after provider(_:didActivate:) and should stop on provider(_:didDeactivate:).",
"Routes hangups and call-detail updates over the existing app-server connection after the initial VoIP push instead of sending more VoIP pushes.",
"Limits CXProvider.reportNewIncomingVoIPPushPayload to notification service extensions that decrypt payloads when the server cannot tell whether content is a VoIP call or other data.",
"Mentions that reportNewIncomingVoIPPushPayload requires the com.apple.developer.usernotifications.filtering entitlement.",
"Does not turn the answer into a generic APNs notification permission or rich-notification guide."
]
},
{
"id": 2,
"prompt": "I need caller ID and call blocking for a spam filter, plus iOS 26 call translation and normal APNs auth-key rotation. Can the Call Directory extension look up each caller from my API as calls arrive? What belongs in CallKit and what should move to another skill?",
"expected_output": "A boundary-aware answer that keeps Call Directory and CallKit call-translation behavior in scope, rejects per-call lookup, uses sorted country-code phone numbers and settings/status APIs, and routes generic APNs auth-key rotation to push notification guidance.",
"files": [],
"expectations": [
"States that Call Directory extensions load bulk caller ID/blocking data in beginRequest(with:) and are not invoked for per-call web lookups.",
"Represents CXCallDirectoryPhoneNumber as country code followed by digits and requires sorted or sequential entries.",
"Mentions checking extension status and opening Call Blocking & Identification settings with CXCallDirectoryManager when needed.",
"Covers iOS 26 call translation with supportsAudioTranslation and CXSetTranslatingCallAction at a concise CallKit level.",
"Warns not to deactivate upstream audio on mute while call translation is active.",
"Routes generic APNs auth-key rotation or normal remote-notification registration to push-notifications guidance instead of expanding CallKit scope."
]
}
]
}
CallKit + PushKit Extended Patterns
Overflow reference for the callkit skill. Contains advanced patterns that exceed the main skill file's scope.
Contents
- Full Call Manager
- Hold and Mute Actions
- Multiple Concurrent Calls
- Call State Tracking
- Encrypted VoIP Push Filtering
- Call Directory Incremental Updates
- Testing VoIP Locally
Full Call Manager
import CallKit
import AVFoundation
import PushKit
@Observable
@MainActor
final class VoIPCallManager: NSObject {
let provider: CXProvider
let callController = CXCallController()
private(set) var activeCalls: [UUID: CallInfo] = [:]
struct CallInfo {
let uuid: UUID
let handle: String
let isOutgoing: Bool
var isOnHold: Bool = false
var isMuted: Bool = false
var isConnected: Bool = false
}
override init() {
let config = CXProviderConfiguration()
config.localizedName = "My VoIP"
config.supportsVideo = true
config.maximumCallGroups = 2
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.phoneNumber]
config.includesCallsInRecents = true
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
// MARK: - Incoming
func reportIncoming(
uuid: UUID,
handle: String,
callerName: String,
hasVideo: Bool
) async throws {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
update.localizedCallerName = callerName
update.hasVideo = hasVideo
update.supportsHolding = true
update.supportsDTMF = true
update.supportsGrouping = false
update.supportsUngrouping = false
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()
}
}
}
activeCalls[uuid] = CallInfo(
uuid: uuid, handle: handle, isOutgoing: false
)
}
// MARK: - Outgoing
func startCall(handle: String, hasVideo: Bool) {
let uuid = UUID()
let cxHandle = CXHandle(type: .phoneNumber, value: handle)
let action = CXStartCallAction(call: uuid, handle: cxHandle)
action.isVideo = hasVideo
callController.request(
CXTransaction(action: action)
) { error in
if let error {
print("Start call failed: \(error)")
}
}
activeCalls[uuid] = CallInfo(
uuid: uuid, handle: handle, isOutgoing: true
)
}
// MARK: - Actions
func endCall(uuid: UUID) {
let action = CXEndCallAction(call: uuid)
callController.request(CXTransaction(action: action)) { error in
if let error { print("End call failed: \(error)") }
}
}
func setHeld(uuid: UUID, onHold: Bool) {
let action = CXSetHeldCallAction(call: uuid, onHold: onHold)
callController.request(CXTransaction(action: action)) { error in
if let error { print("Hold failed: \(error)") }
}
}
func setMuted(uuid: UUID, muted: Bool) {
let action = CXSetMutedCallAction(call: uuid, muted: muted)
callController.request(CXTransaction(action: action)) { error in
if let error { print("Mute failed: \(error)") }
}
}
}Hold and Mute Actions
extension VoIPCallManager: CXProviderDelegate {
nonisolated func providerDidReset(_ provider: CXProvider) {
Task { @MainActor in
for uuid in activeCalls.keys {
disconnectCall(uuid)
}
activeCalls.removeAll()
}
}
nonisolated func provider(
_ provider: CXProvider,
perform action: CXSetHeldCallAction
) {
Task { @MainActor in
activeCalls[action.callUUID]?.isOnHold = action.isOnHold
if action.isOnHold {
pauseAudio(for: action.callUUID)
} else {
resumeAudio(for: action.callUUID)
}
action.fulfill()
}
}
nonisolated func provider(
_ provider: CXProvider,
perform action: CXSetMutedCallAction
) {
Task { @MainActor in
activeCalls[action.callUUID]?.isMuted = action.isMuted
// If call translation is active, mute app input without deactivating
// upstream audio that translated audio may need.
setMicrophoneMuted(action.isMuted)
action.fulfill()
}
}
nonisolated func provider(
_ provider: CXProvider,
perform action: CXAnswerCallAction
) {
Task { @MainActor in
configureAudioSession()
connectToServer(callUUID: action.callUUID) { success in
if success {
activeCalls[action.callUUID]?.isConnected = true
action.fulfill()
} else {
provider.reportCall(
with: action.callUUID,
endedAt: Date(),
reason: .failed
)
action.fail()
}
}
}
}
nonisolated func provider(
_ provider: CXProvider,
perform action: CXStartCallAction
) {
Task { @MainActor in
configureAudioSession()
provider.reportOutgoingCall(
with: action.callUUID,
startedConnectingAt: Date()
)
connectToServer(callUUID: action.callUUID)
provider.reportOutgoingCall(
with: action.callUUID,
connectedAt: Date()
)
activeCalls[action.callUUID]?.isConnected = true
action.fulfill()
}
}
nonisolated func provider(
_ provider: CXProvider,
perform action: CXEndCallAction
) {
Task { @MainActor in
disconnectCall(action.callUUID)
activeCalls.removeValue(forKey: action.callUUID)
action.fulfill()
}
}
nonisolated func provider(
_ provider: CXProvider,
didActivate audioSession: AVAudioSession
) {
Task { @MainActor in
startAudioEngine()
}
}
nonisolated func provider(
_ provider: CXProvider,
didDeactivate audioSession: AVAudioSession
) {
Task { @MainActor in
stopAudioEngine()
}
}
}Multiple Concurrent Calls
When a second call arrives while one is active, CallKit automatically puts the first call on hold. Handle the hold action to pause your audio stream:
nonisolated func provider(
_ provider: CXProvider,
perform action: CXSetHeldCallAction
) {
Task { @MainActor in
if action.isOnHold {
// Pause the RTP stream for this call
pauseMediaStream(for: action.callUUID)
} else {
// Resume the RTP stream
resumeMediaStream(for: action.callUUID)
}
activeCalls[action.callUUID]?.isOnHold = action.isOnHold
action.fulfill()
}
}Configure maximumCallGroups and maximumCallsPerCallGroup in CXProviderConfiguration to control how many concurrent calls your app supports.
Call State Tracking
Use CXCallObserver to monitor call state changes from outside the provider delegate:
import CallKit
final class CallStateObserver: NSObject, CXCallObserverDelegate {
let observer = CXCallObserver()
override init() {
super.init()
observer.setDelegate(self, queue: nil)
}
func callObserver(
_ callObserver: CXCallObserver,
callChanged call: CXCall
) {
if call.hasEnded {
print("Call \(call.uuid) ended")
} else if call.hasConnected {
print("Call \(call.uuid) connected")
} else if call.isOutgoing {
print("Outgoing call \(call.uuid) ringing")
} else {
print("Incoming call \(call.uuid) ringing")
}
}
}Encrypted VoIP Push Filtering
Use a notification service extension with CXProvider.reportNewIncomingVoIPPushPayload only when server-side metadata encryption means the server cannot determine whether the outgoing notification is a VoIP call request or some other data. If the server knows the content is a VoIP call, send a normal PushKit VoIP push instead.
import UserNotifications
import CallKit
final class NotificationService: UNNotificationServiceExtension {
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void
) {
guard let encryptedPayload = request.content
.userInfo["encrypted"] as? [AnyHashable: Any] else {
contentHandler(request.content)
return
}
let decryptedPayload = decryptPayload(encryptedPayload)
CXProvider.reportNewIncomingVoIPPushPayload(
decryptedPayload
) { error in
if let error {
// Show a missed-call notification instead
let content = UNMutableNotificationContent()
content.title = "Missed Call"
content.body = decryptedPayload["callerName"] as? String ?? ""
contentHandler(content)
} else {
// Call was reported; suppress the notification
contentHandler(UNNotificationContent())
}
}
}
}This requires the com.apple.developer.usernotifications.filtering entitlement.
Call Directory Incremental Updates
After the first full load, use incremental updates to add or remove entries without reloading the entire dataset:
private func addOrRemoveIncrementalEntries(
to context: CXCallDirectoryExtensionContext
) {
let removedNumbers: [CXCallDirectoryPhoneNumber] = fetchRemovedNumbers()
for number in removedNumbers {
context.removeBlockingEntry(withPhoneNumber: number)
context.removeIdentificationEntry(withPhoneNumber: number)
}
let newBlocked: [CXCallDirectoryPhoneNumber] = fetchNewBlockedNumbers()
for number in newBlocked.sorted() {
context.addBlockingEntry(withNextSequentialPhoneNumber: number)
}
let newIdentified: [(CXCallDirectoryPhoneNumber, String)] = fetchNewIdentified()
for (number, label) in newIdentified.sorted(by: { $0.0 < $1.0 }) {
context.addIdentificationEntry(
withNextSequentialPhoneNumber: number,
label: label
)
}
}Call Directory data is bulk data. The system calls beginRequest(with:) when loading the extension, not for each individual incoming call, so keep web lookups and dataset sync in the containing app before reloading the extension.
Testing VoIP Locally
Simulating VoIP Pushes
Use the Push Notifications Console or a command-line tool to send test pushes. The payload must target the VoIP topic (<bundle-id>.voip):
{
"aps": {},
"handle": "+15551234567",
"callerName": "Test Caller",
"hasVideo": false
}Testing Without a Server
For development, you can bypass PushKit and directly call the incoming call reporting method:
#if DEBUG
func simulateIncomingCall() {
let uuid = UUID()
Task {
try? await CallManager.shared.reportIncomingCall(
uuid: uuid,
handle: "+15551234567",
hasVideo: false
)
}
}
#endifChecking Extension Status
Verify that the Call Directory extension is enabled:
CXCallDirectoryManager.sharedInstance.getEnabledStatusForExtension(
withIdentifier: "com.example.app.CallDirectory"
) { status, error in
switch status {
case .enabled:
print("Extension is enabled")
case .disabled:
print("Extension is disabled -- prompt user to enable in Settings")
case .unknown:
print("Status unknown")
@unknown default:
break
}
}Related skills
How it compares
Use callkit for Apple-specific VoIP push and reporting rules rather than generic push notification skills that omit mustReport semantics.
FAQ
Who is callkit for?
Developers and software engineers working with callkit patterns from the skill documentation.
When should I use callkit?
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 cal
Is callkit safe to install?
Review the Security Audits panel on this page before installing in production.