Skip to main content
iOS CallKit only works on a real device. The CallKit framework is not available on the iOS Simulator. Do not test incoming call functionality in the Simulator.
1

Configure Info.plist

Open ios/Runner/Info.plist and add the required background modes. At minimum, voip and remote-notification are required:
<key>UIBackgroundModes</key>
<array>
    <string>voip</string>
    <string>remote-notification</string>
    <string>processing</string> <!-- add this if needed -->
</array>
2

Enable Voice over IP capability in Xcode

Open your project in Xcode and navigate to:Xcode Project → Your Target → Signing & CapabilitiesClick + Capability and add Background Modes. Then check Voice over IP in the list of background modes.
This capability must be explicitly enabled in Xcode in addition to the Info.plist entry. Skipping this step means your app will not receive VoIP pushes in the background.
3

Add a CallKit icon

Add a CallKitLogo image set to your ios/Runner/Images.xcassets folder in Xcode. This image is displayed inside the system CallKit UI when an incoming call arrives.The default icon name used by the plugin is CallKitLogo. You can change this by setting iconName in IOSParams:
ios: IOSParams(
  iconName: 'CallKitLogo', // matches the name in Images.xcassets
),
4

Configure AppDelegate.swift

Your AppDelegate must conform to PKPushRegistryDelegate and CallkitIncomingAppDelegate. This wires up VoIP push handling and call event callbacks.Open ios/Runner/AppDelegate.swift and replace or update it with the following:
import UIKit
import CallKit
import AVFAudio
import PushKit
import Flutter
import flutter_callkit_incoming

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        // Setup VOIP
        let mainQueue = DispatchQueue.main
        let voipRegistry: PKPushRegistry = PKPushRegistry(queue: mainQueue)
        voipRegistry.delegate = self
        voipRegistry.desiredPushTypes = [PKPushType.voIP]

        // Use if using WebRTC
        // RTCAudioSession.sharedInstance().useManualAudio = true
        // RTCAudioSession.sharedInstance().isAudioEnabled = false

        // Add for Missed call notification
        if #available(iOS 10.0, *) {
            UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    // Handle updated push credentials
    func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
        print(credentials.token)
        let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
        // Save deviceToken to your server
        SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
    }

    func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
        print("didInvalidatePushTokenFor")
        SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
    }

    // Handle incoming VoIP pushes
    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
        print("didReceiveIncomingPushWith")
        guard type == .voIP else { return }

        let id = payload.dictionaryPayload["id"] as? String ?? ""
        let nameCaller = payload.dictionaryPayload["nameCaller"] as? String ?? ""
        let handle = payload.dictionaryPayload["handle"] as? String ?? ""
        let isVideo = payload.dictionaryPayload["isVideo"] as? Bool ?? false

        let data = flutter_callkit_incoming.Data(id: id, nameCaller: nameCaller, handle: handle, type: isVideo ? 1 : 0)
        // Set more data as needed
        // data.extra = ["user": "abc@123", "platform": "ios"]
        SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(data, fromPushKit: true) {
            completion()
        }
    }

    // Called when the user accepts a call
    func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
        let json = ["action": "ACCEPT", "data": call.data.toJSON()] as [String: Any]
        print("LOG: onAccept")
        // Perform your API call here, then call action.fulfill() when done
        // (e.g., when WebRTC connection is established)
        action.fulfill()
    }

    // Called when the user declines a call
    func onDecline(_ call: Call, _ action: CXEndCallAction) {
        let json = ["action": "DECLINE", "data": call.data.toJSON()] as [String: Any]
        print("LOG: onDecline")
        // Perform your API call here, then call action.fulfill() when done
        action.fulfill()
    }

    // Called when the user ends a call
    func onEnd(_ call: Call, _ action: CXEndCallAction) {
        let json = ["action": "END", "data": call.data.toJSON()] as [String: Any]
        print("LOG: onEnd")
        // Perform your API call here, then call action.fulfill() when done
        action.fulfill()
    }

    // Called when the incoming call times out (missed)
    func onTimeOut(_ call: Call) {
        let json = ["action": "TIMEOUT", "data": call.data.toJSON()] as [String: Any]
        print("LOG: onTimeOut")
    }

    // Called when the audio session is activated (e.g., call accepted)
    func didActivateAudioSession(_ audioSession: AVAudioSession) {
        // Use if using WebRTC:
        // RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
        // RTCAudioSession.sharedInstance().isAudioEnabled = true
    }

    // Called when the audio session is deactivated (e.g., call ended)
    func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
        // Use if using WebRTC:
        // RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession)
        // RTCAudioSession.sharedInstance().isAudioEnabled = false
    }
}
5

Set up missed call notifications

To show missed call notifications and handle the Call back action while the app is in the foreground, add two UNUserNotificationCenter delegate methods to your AppDelegate.These are already included in the full AppDelegate.swift example above. The key methods are:
// Show notification when app is in the foreground
override func userNotificationCenter(_ center: UNUserNotificationCenter,
                                     willPresent notification: UNNotification,
                                     withCompletionHandler completionHandler:
                                     @escaping (UNNotificationPresentationOptions) -> Void) {

    CallkitNotificationManager.shared.userNotificationCenter(center, willPresent: notification, withCompletionHandler: completionHandler)
}

// Handle callback action tapped in a missed call notification
override func userNotificationCenter(_ center: UNUserNotificationCenter,
                                     didReceive response: UNNotificationResponse,
                                     withCompletionHandler completionHandler: @escaping () -> Void) {
    if response.actionIdentifier == CallkitNotificationManager.CALLBACK_ACTION {
        let data = response.notification.request.content.userInfo as? [String: Any]
        SwiftFlutterCallkitIncomingPlugin.sharedInstance?.sendCallbackEvent(data)
    }
    completionHandler()
}
This delegates foreground notification display to CallkitNotificationManager and routes the Call back tap to the Flutter event stream as a actionCallCallback event.

Build docs developers (and LLMs) love