Skip to main content
Implement native VoIP calling that works when your app is in the background or killed. This guide shows how to integrate Apple’s CallKit framework with CometChat to display system call UI, handle calls from the lock screen, and provide a native calling experience.

Overview

VoIP calling differs from basic in-app ringing by leveraging Apple’s CallKit to:
  • Show incoming calls on lock screen with system UI
  • Handle calls when app is in background or killed
  • Integrate with CarPlay, Bluetooth, and other audio accessories
  • Provide consistent call experience across iOS devices

Prerequisites

Before implementing VoIP calling, ensure you have:
This documentation builds on the Ringing functionality. Make sure you understand basic call signaling before implementing VoIP.

Architecture Overview

The VoIP implementation consists of several components working together:
ComponentPurpose
PushKitReceives VoIP push notifications when app is in background
CXProviderCallKit provider that manages call state with the system
CXCallControllerRequests call actions (start, end, hold) from the system
CallManagerYour custom class that coordinates between CometChat and CallKit

Step 1: Configure Push Notifications

VoIP push notifications are essential for receiving incoming calls when your app is not in the foreground.

1.1 Enable VoIP Capability

In Xcode, add the following capabilities to your app:
  1. Go to your target’s “Signing & Capabilities” tab
  2. Add “Push Notifications” capability
  3. Add “Background Modes” capability and enable:
    • Voice over IP
    • Background fetch
    • Remote notifications

1.2 Register for VoIP Push

import PushKit

class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var voipRegistry: PKPushRegistry!
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        // Initialize CometChat
        // CometChat.init(...)
        
        // Register for VoIP push notifications
        registerForVoIPPush()
        
        return true
    }
    
    private func registerForVoIPPush() {
        voipRegistry = PKPushRegistry(queue: DispatchQueue.main)
        voipRegistry.delegate = self
        voipRegistry.desiredPushTypes = [.voIP]
    }
}

1.3 Handle VoIP Push Registration

extension AppDelegate: PKPushRegistryDelegate {
    
    func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
        let token = pushCredentials.token.map { String(format: "%02.2hhx", $0) }.joined()
        
        // Register VoIP token with CometChat
        CometChat.registerTokenForPushNotification(token: token) { success in
            print("VoIP token registered successfully")
        } onError: { error in
            print("VoIP token registration failed: \(error?.errorDescription ?? "")")
        }
    }
    
    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
        
        guard type == .voIP else {
            completion()
            return
        }
        
        // Extract call data from payload
        let data = payload.dictionaryPayload
        guard let sessionId = data["sessionId"] as? String,
              let callerName = data["senderName"] as? String,
              let callerUid = data["senderUid"] as? String else {
            completion()
            return
        }
        
        let callType = data["callType"] as? String ?? "video"
        let hasVideo = callType == "video"
        
        // Report incoming call to CallKit
        CallManager.shared.reportIncomingCall(
            sessionId: sessionId,
            callerName: callerName,
            callerUid: callerUid,
            hasVideo: hasVideo
        ) { error in
            completion()
        }
    }
}

Step 2: Create CallManager

The CallManager coordinates between CometChat and CallKit:
import CallKit

class CallManager: NSObject {
    
    static let shared = CallManager()
    
    private let provider: CXProvider
    private let callController = CXCallController()
    
    private var activeCallUUID: UUID?
    private var activeSessionId: String?
    private var activeCallerUid: String?
    
    private override init() {
        let configuration = CXProviderConfiguration()
        configuration.supportsVideo = true
        configuration.maximumCallsPerCallGroup = 1
        configuration.supportedHandleTypes = [.generic]
        configuration.iconTemplateImageData = UIImage(named: "CallIcon")?.pngData()
        
        provider = CXProvider(configuration: configuration)
        
        super.init()
        provider.setDelegate(self, queue: nil)
    }
    
    func reportIncomingCall(sessionId: String, callerName: String, callerUid: String, hasVideo: Bool, completion: @escaping (Error?) -> Void) {
        let uuid = UUID()
        activeCallUUID = uuid
        activeSessionId = sessionId
        activeCallerUid = callerUid
        
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .generic, value: callerUid)
        update.localizedCallerName = callerName
        update.hasVideo = hasVideo
        update.supportsGrouping = false
        update.supportsUngrouping = false
        update.supportsHolding = true
        update.supportsDTMF = false
        
        provider.reportNewIncomingCall(with: uuid, update: update) { error in
            if let error = error {
                print("Failed to report incoming call: \(error.localizedDescription)")
                self.activeCallUUID = nil
                self.activeSessionId = nil
            }
            completion(error)
        }
    }
    
    func startOutgoingCall(sessionId: String, receiverName: String, receiverUid: String, hasVideo: Bool) {
        let uuid = UUID()
        activeCallUUID = uuid
        activeSessionId = sessionId
        
        let handle = CXHandle(type: .generic, value: receiverUid)
        let startCallAction = CXStartCallAction(call: uuid, handle: handle)
        startCallAction.isVideo = hasVideo
        startCallAction.contactIdentifier = receiverName
        
        let transaction = CXTransaction(action: startCallAction)
        callController.request(transaction) { error in
            if let error = error {
                print("Failed to start call: \(error.localizedDescription)")
            }
        }
    }
    
    func endCall() {
        guard let uuid = activeCallUUID else { return }
        
        let endCallAction = CXEndCallAction(call: uuid)
        let transaction = CXTransaction(action: endCallAction)
        
        callController.request(transaction) { error in
            if let error = error {
                print("Failed to end call: \(error.localizedDescription)")
            }
        }
    }
    
    func reportCallEnded(reason: CXCallEndedReason) {
        guard let uuid = activeCallUUID else { return }
        provider.reportCall(with: uuid, endedAt: Date(), reason: reason)
        activeCallUUID = nil
        activeSessionId = nil
    }
    
    func reportCallConnected() {
        guard let uuid = activeCallUUID else { return }
        provider.reportOutgoingCall(with: uuid, connectedAt: Date())
    }
}

Step 3: Implement CXProviderDelegate

Handle CallKit callbacks for user actions:
extension CallManager: CXProviderDelegate {
    
    func providerDidReset(_ provider: CXProvider) {
        // Clean up any active calls
        activeCallUUID = nil
        activeSessionId = nil
    }
    
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        // User tapped "Answer" on the call UI
        guard let sessionId = activeSessionId else {
            action.fail()
            return
        }
        
        // Accept the call via CometChat
        CometChat.acceptCall(sessionID: sessionId) { call in
            action.fulfill()
            
            // Launch call UI
            DispatchQueue.main.async {
                self.launchCallViewController(sessionId: sessionId)
            }
        } onError: { error in
            print("Failed to accept call: \(error?.errorDescription ?? "")")
            action.fail()
        }
    }
    
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        // User tapped "Decline" or "End Call"
        guard let sessionId = activeSessionId else {
            action.fulfill()
            return
        }
        
        // Reject or end the call via CometChat
        CometChat.rejectCall(sessionID: sessionId, status: .rejected) { call in
            action.fulfill()
        } onError: { error in
            action.fulfill()
        }
        
        activeCallUUID = nil
        activeSessionId = nil
    }
    
    func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
        if action.isMuted {
            CallSession.shared.muteAudio()
        } else {
            CallSession.shared.unMuteAudio()
        }
        action.fulfill()
    }
    
    func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
        // Handle hold/unhold if needed
        action.fulfill()
    }
    
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        // Configure audio session for call
        do {
            try audioSession.setCategory(.playAndRecord, mode: .voiceChat)
            try audioSession.setActive(true)
        } catch {
            print("Failed to configure audio session: \(error)")
        }
    }
    
    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        // Clean up audio session
        do {
            try audioSession.setActive(false)
        } catch {
            print("Failed to deactivate audio session: \(error)")
        }
    }
    
    private func launchCallViewController(sessionId: String) {
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let rootViewController = windowScene.windows.first?.rootViewController else {
            return
        }
        
        let callVC = CallViewController()
        callVC.sessionId = sessionId
        callVC.modalPresentationStyle = .fullScreen
        
        rootViewController.present(callVC, animated: true)
    }
}

Step 4: Handle Call End Events

Update CallKit when the call ends from the remote side:
class CallViewController: UIViewController, SessionStatusListener {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        CallSession.shared.addSessionStatusListener(self)
    }
    
    func onSessionLeft() {
        CallManager.shared.reportCallEnded(reason: .remoteEnded)
        dismiss(animated: true)
    }
    
    func onConnectionClosed() {
        CallManager.shared.reportCallEnded(reason: .failed)
        dismiss(animated: true)
    }
    
    func onSessionJoined() {
        CallManager.shared.reportCallConnected()
    }
    
    func onSessionTimedOut() {
        CallManager.shared.reportCallEnded(reason: .unanswered)
    }
    
    func onConnectionLost() {}
    func onConnectionRestored() {}
}