Push Notifications - iOS
Overview
iOS requires VoIP apps to use PushKit for incoming call notifications and CallKit for presenting the system call UI. These two frameworks are tightly coupled — Apple enforces that every VoIP push results in a reportNewIncomingCall to CallKit. Failure to comply causes iOS to permanently revoke push delivery to the device.
The Vonage Client SDK integrates with both frameworks. Your app is responsible for:
- Registering for VoIP pushes and forwarding the token to Vonage
- Receiving push payloads and passing them to the SDK for processing
- Presenting incoming calls via CallKit
- Managing the audio session lifecycle exclusively through CallKit callbacks
- Maintaining a valid SDK session so calls can be answered
Component Responsibilities
| Component | Responsibility |
|---|---|
| PushKit | Wakes the app from killed/suspended state, delivers push payload |
| Vonage SDK | Decodes push payload, manages WebRTC session, handles call signaling |
| CallKit | Presents system call UI, manages audio session activation |
| Your App | Orchestrates the above, manages JWT/session lifecycle |
Project Setup
1 - Xcode Capabilities
Enable the following in your target's Signing & Capabilities tab:
Push Notifications → enabled
Background Modes → Voice over IP (checked)
→ Background processing (checked)Your entitlements file should contain:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.pushkit.unrestricted-voip</key>
<true/>
</dict>
</plist>WARNING
The aps-environment value is automatically set by Xcode based on provisioning. Do not manually override it.
Restricted VoIP Entitlement
Starting with iOS 16, apps without the com.apple.developer.pushkit.unrestricted-voip entitlement are limited to a small number of PushKit pushes per day. If the system budget is exceeded, push delivery is silently throttled and incoming calls will not wake the app.
This entitlement must be requested from Apple via the entitlement request form. Until it is granted, development builds may work normally (debug builds have a higher budget), but production builds will be throttled.
Ensure this entitlement is present in your .entitlements file and approved by Apple before shipping to the App Store.
2 - Push Certificates
You need an APNs certificate for your App ID, exported as a .p12, and uploaded to the Vonage application your app authenticates against. A modern app-scoped APNs SSL certificate is valid for the .voip topic, so a separate legacy "VoIP Services Certificate" is not required.
Generate a CSR
In Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority, enter your email and a Common Name, leave the CA Email blank, choose Saved to disk, and save the .certSigningRequest.
Create the certificate
- In the Apple Developer Portal → Certificates, Identifiers & Profiles, ensure your App ID has the Push Notifications capability enabled.
- Under Certificates, add an Apple Push Notification service SSL (Sandbox & Production) certificate, select your App ID, and upload the CSR.
- Download the resulting
.cer.
Export the .p12
Double-click the .cer to import it into your login keychain. In My Certificates, find Apple Push Services: <your-bundle-id> — it must have the private key nested under it — then right-click → Export → Personal Information Exchange (.p12). Leave the export password blank if possible (see the upload note below).
Upload via the Vonage Dashboard
- Log in to the Vonage Dashboard.
- Go to Applications → your application (the
application_idyour app's JWT authenticates against). - Open the Enable push notifications tab and upload the
.p12. - The password field is optional and labelled not recommended (Vonage stores it alongside your private key), so a password-less certificate is preferred.
See the Vonage iOS push setup docs for reference.
DANGER
Certificates expire annually. The failure mode is complete and silent — no error is surfaced anywhere in the system. Track the expiry date and renew proactively.
Sandbox vs Production
Xcode debug builds use the sandbox APNs environment; TestFlight and App Store builds use production. Upload separate certificates for each environment and always determine the isSandbox flag at compile time using #if DEBUG — never hardcode it.
3 - Frameworks
Add CallKit under Frameworks, Libraries, and Embedded Content in your target's General tab. Import as needed:
import PushKit
import CallKit
import VonageClientSDKVoice
import AVFoundation4 - SDK Initialization
Initialize VGVoiceClient once, early in the app lifecycle (e.g. in your AppDelegate or a singleton manager). Set its delegate before registering for push or creating sessions:
let config = VGClientInitConfig(loggingLevel: .info)
#if targetEnvironment(simulator)
config.enableWebsocketInvites = true
#endif
let client = VGVoiceClient(config)
client.delegate = selfTIP
Request microphone permission early in the app lifecycle — ideally in application(_:didFinishLaunchingWithOptions:):
AVAudioSession.sharedInstance().requestRecordPermission { granted in
print("Microphone permission: \(granted)")
}If the user denies microphone access, calls will connect but the remote party will hear silence.
PushKit Integration
Register for VoIP Pushes
Create a PKPushRegistry and set its desired push types at app launch, before any call can arrive.
import PushKit
let pushRegistry = PKPushRegistry(queue: .main)
pushRegistry.delegate = self
pushRegistry.desiredPushTypes = [.voIP]
PKPushRegistryDelegate
Implement the three delegate methods. The third — didReceiveIncomingPushWith — is the most critical:
extension AppDelegate: PKPushRegistryDelegate {
// Called when a new VoIP token is issued or rotated
func pushRegistry(_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType) {
guard type == .voIP else { return }
CallManager.shared.voipTokenDidUpdate(pushCredentials.token)
}
// Called when the token is invalidated (e.g. app reinstall)
func pushRegistry(_ registry: PKPushRegistry,
didInvalidatePushTokenFor type: PKPushType) {
CallManager.shared.unregisterDeviceToken()
}
// Called when an inbound VoIP push is received
func pushRegistry(_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void) {
guard type == .voIP else { completion(); return }
CallManager.shared.handleIncomingPush(payload: payload, completion: completion)
}
}Registering the Token with Vonage
Register with client.registerVoipToken(_:isSandbox:) only when a Vonage session exists. Store the returned deviceId — you need it to unregister on logout.
#if DEBUG
let isSandbox = true
#else
let isSandbox = false
#endif
client.registerVoipToken(pushToken, withDeviceToken: nil, isSandbox: isSandbox) { error, deviceId in
if let error = error {
print("Failed to register push token: \(error.localizedDescription)")
return
}
// Store deviceId for unregistration on logout
print("Push token registered, deviceId: \(deviceId ?? "")")
}
INFO
Do not call registerVoipToken on every session restore. Once registered, the token remains valid until it rotates or the user logs out. Re-registering on each createSession accumulates device entries and can result in duplicate pushes.
Session Management
A Vonage session (client.createSession(jwt)) is required before the SDK can process call invites or perform call actions. When a VoIP push arrives and the app is killed or the session has expired, the session must be recreated. How fast this happens directly determines whether users can successfully answer calls.
The Most Common Production Failure
Don't
On push receipt: call your auth server -> get fresh JWT -> call createSession. This adds two serial network hops in a background execution window with poor signal. The user sees the CallKit screen, taps Answer before the session is ready, and the call fails silently.
Do
At user login: persist the JWT (and a refresh token) locally. On push receipt: call createSession with the stored JWT directly. Zero backend calls on the fast path.
JWT Strategy
Follow a priority chain — use the fastest available path:
| Priority | Condition | Action | Typical Latency |
|---|---|---|---|
| 1st | Active session exists | Return immediately — no work needed | 0 ms |
| 2nd | Stored JWT is valid | client.createSession(storedJWT) | 100-400 ms |
| 3rd | JWT expired, refresh token available | Refresh endpoint -> new JWT -> createSession | 300-1000 ms |
| 4th | No credentials | Log warning; call cannot be answered | 0 ms |
Session Creation
do {
let sessionId = try await client.createSession(jwt)
print("Session created: \(sessionId)")
} catch {
print("Failed to create session: \(error.localizedDescription)")
}
Session Restoration on Push Receipt
func restoreSessionIfNeeded() {
guard activeSessionId == nil else {
print("Session already active")
return
}
if let jwt = UserDefaults.standard.string(forKey: "vonage_jwt") {
// Fast path — use stored JWT directly
createSession(jwt: jwt)
} else if let refreshToken = storedRefreshToken {
// Slow path — exchange refresh token for a new JWT first
refreshJWT(refreshToken) { [weak self] newJWT in
self?.createSession(jwt: newJWT)
}
} else {
print("No credentials available for session restoration")
}
}Processing Push Payloads
This is the most timing-sensitive piece of the integration. Two operations must happen from handleIncomingPush, and the order relationship between them is non-obvious.
The Concurrent Pattern (Required)
DANGER
Never await session restoration before calling processCallInvitePushData. Gating push processing behind createSession delays reportNewIncomingCall by a full network round-trip. Apple treats this as a failure to handle the push — PushKit silently stops delivering future pushes to the device.
Implementation
client.processCallInvitePushData(payload.dictionaryPayload)
The full implementation pattern:
private var pendingPushCompletion: (() -> Void)?
func handleIncomingPush(payload: PKPushPayload, completion: @escaping () -> Void) {
// 1. Validate: only handle Vonage incoming-call pushes
let pushType = VGVoiceClient.vonagePushType(payload.dictionaryPayload)
guard pushType == .incomingCall else {
// Must still report to CallKit — failing to do so breaks future push delivery.
let fakeUUID = UUID()
callProvider.reportNewIncomingCall(with: fakeUUID, update: CXCallUpdate()) { _ in
self.callProvider.reportCall(with: fakeUUID, endedAt: .now, reason: .failed)
completion()
}
return
}
// 2. Store completion — will be called after reportNewIncomingCall
pendingPushCompletion = completion
// 3a. Restore session concurrently — non-blocking
restoreSessionIfNeeded()
// 3b. Process the push immediately — triggers didReceiveInviteForCall
client.processCallInvitePushData(payload.dictionaryPayload)
}WARNING
Even if the push is malformed, expired, or a duplicate, you must still call reportNewIncomingCall and immediately end the call. Skipping it breaks push delivery permanently.
CallKit Integration
CXProvider Setup
Create CXProvider and CXCallController once and keep them for the lifetime of your manager. Do not recreate them on each call or push.
class CallManager: NSObject {
static let shared = CallManager()
private let callProvider: CXProvider
private let callController = CXCallController()
private override init() {
let config = CXProviderConfiguration()
config.supportsVideo = false
config.maximumCallGroups = 1
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.generic]
config.supportsDTMF = true
config.supportsHolding = true
callProvider = CXProvider(configuration: config)
super.init()
callProvider.setDelegate(self, queue: nil)
}
}Reporting an Incoming Call
Called from didReceiveInviteForCall (see SDK Delegate section). The PushKit completion must be invoked inside the reportNewIncomingCall callback — never before it.
let update = CXCallUpdate()
let handleType: CXHandle.HandleType = channelType == .phone ? .phoneNumber : .generic
update.remoteHandle = CXHandle(type: handleType, value: caller)
update.hasVideo = false
provider.reportNewIncomingCall(with: callId, update: update) { error in
if let error = error {
print("Failed to report incoming call: \(error.localizedDescription)")
}
// Always call the PushKit completion inside this callback
pushCompletion()
}
CXHandle type
Use .phoneNumber for the CXHandle type when VGVoiceChannelType is .phone (PSTN callers with E.164 numbers). Use .generic for app-to-app callers. This affects how iOS displays the caller in the native call UI and Recents.
CXProviderDelegate - User Actions
extension CallManager: CXProviderDelegate {
// Called when CallKit resets (e.g. during system errors or when the
// provider is invalidated). Clean up all active call state.
func providerDidReset(_ provider: CXProvider) {
activeCall = nil
}
// User tapped Answer
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
guard let callId = activeCallId else { action.fail(); return }
client.answer(callId) { error in
if error == nil {
action.fulfill()
} else {
action.fail()
}
}
}
// User tapped End (covers both Reject while ringing and Hang up during call)
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
guard let callId = activeCallId else { action.fail(); return }
let complete: (Error?) -> Void = { error in
error == nil ? action.fulfill() : action.fail()
}
if isCallRinging {
client.reject(callId, callback: complete)
} else {
client.hangup(callId, callback: complete)
}
}
// Mute toggle
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
guard let callId = activeCallId else { action.fail(); return }
if action.isMuted {
client.mute(callId) { _ in action.fulfill() }
} else {
client.unmute(callId) { _ in action.fulfill() }
}
}
// Hold toggle
func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
guard let callId = activeCallId else { action.fail(); return }
if action.isOnHold {
client.enableEarmuff(callId) { _ in action.fulfill() }
} else {
client.disableEarmuff(callId) { _ in action.fulfill() }
}
}
// DTMF
func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) {
guard let callId = activeCallId else { action.fail(); return }
client.sendDTMF(callId, withDigits: action.digits) { _ in action.fulfill() }
}
}Audio Session Handoff
// In CXProviderDelegate - provider(_:didActivate:)
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
VGVoiceClient.enableAudio(audioSession)
}
// In CXProviderDelegate - provider(_:didDeactivate:)
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
VGVoiceClient.disableAudio(audioSession)
}
DANGER
Always enable and disable Vonage audio exclusively inside didActivate / didDeactivate. Enabling audio before CallKit activates the session, or from a UI button handler, causes audio routing bugs with Bluetooth, AirPods, and CarPlay.
SDK Delegate
VGVoiceClientDelegate - Inbound Call Events
extension CallManager: VGVoiceClientDelegate {
func voiceClient(_ client: VGVoiceClient,
didReceiveInviteForCall callId: VGCallId,
from caller: String,
with type: VGVoiceChannelType) {
guard let callUUID = UUID(uuidString: callId) else { return }
activeCallId = callId
activeCallUUID = callUUID
isCallRinging = true
// Report to CallKit, then call the PushKit completion in the callback
reportIncomingCall(callUUID: callUUID, caller: caller, type: type,
pushCompletion: pendingPushCompletion ?? {})
pendingPushCompletion = nil
}
// Remote party hung up or the call timed out
func voiceClient(_ client: VGVoiceClient,
didReceiveHangupForCall callId: VGCallId,
withQuality callQuality: VGRTCQuality,
reason: VGHangupReason) {
guard let uuid = activeCallUUID else { return }
let cxReason: CXCallEndedReason = switch reason {
case .remoteReject: .declinedElsewhere
case .remoteHangup, .localHangup: .remoteEnded
case .remoteNoAnswerTimeout: .unanswered
default: .failed
}
callProvider.reportCall(with: uuid, endedAt: .now, reason: cxReason)
cleanUpCallState()
}
// Caller cancelled the invite before it was answered
func voiceClient(_ client: VGVoiceClient,
didReceiveInviteCancelForCall callId: VGCallId,
with reason: VGVoiceInviteCancelReason) {
guard let uuid = activeCallUUID else { return }
let cxReason: CXCallEndedReason = switch reason {
case .answeredElsewhere: .answeredElsewhere
case .rejectedElsewhere: .declinedElsewhere
case .remoteCancel: .remoteEnded
case .remoteTimeout: .unanswered
default: .failed
}
callProvider.reportCall(with: uuid, endedAt: .now, reason: cxReason)
cleanUpCallState()
}
private func cleanUpCallState() {
activeCallId = nil
activeCallUUID = nil
isCallRinging = false
}
}INFO
Always call callProvider.reportCall(with:endedAt:reason:) for every termination path — remote hangup, remote cancel, media timeout, local reject. Missing any of these leaves CallKit in an inconsistent state and can break the call log and future call reporting.
Outbound Calls
For outbound calls the flow is reversed: your app initiates the call via the SDK, then uses CXCallController to inform CallKit.
// 1. Initiate the call via the SDK
func startCall(to callee: String) {
let callContext = ["callee": callee]
client.serverCall(callContext) { [weak self] error, callId in
guard let callId,
let callUUID = UUID(uuidString: callId) else { return }
self?.activeCallId = callId
self?.activeCallUUID = callUUID
// 2. Notify CallKit so the in-call UI appears
let handle = CXHandle(type: .generic, value: callee)
let action = CXStartCallAction(call: callUUID, handle: handle)
let transaction = CXTransaction(action: action)
self?.callController.request(transaction) { [weak self] error in
guard error == nil else { return }
self?.callProvider.reportOutgoingCall(with: callUUID, startedConnectingAt: .now)
}
}
}
// 3. CXStartCallAction — CallKit confirming the transaction
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
action.fulfill()
}
// 4. When the remote side answers
func voiceClient(_ client: VGVoiceClient,
didReceiveLegStatusUpdateForCall callId: VGCallId,
withLegId legId: String,
andStatus status: VGLegStatus) {
guard status == .answered, let uuid = activeCallUUID else { return }
callProvider.reportOutgoingCall(with: uuid, connectedAt: .now)
}Error Handling
The SDK fires didReceiveSessionErrorWith(reason:) when the underlying WebSocket is disrupted during an active session. Always attempt to restore the session automatically.
func client(_ client: VGBaseClient,
didReceiveSessionErrorWith reason: VGSessionErrorReason) {
print("Session error: \(reason)")
switch reason {
case .tokenExpired:
// The stored JWT is now invalid — skip it, use refresh token
refreshAndRestoreSession()
case .pingTimeout, .transportClosed:
// Transient network issue — try the stored JWT first
restoreSessionIfNeeded()
default:
restoreSessionIfNeeded()
}
}
private func refreshAndRestoreSession() {
guard let refreshToken = storedRefreshToken else {
clearSession()
return
}
refreshJWT(refreshToken) { [weak self] result in
switch result {
case .success(let newJWT):
self?.createSession(jwt: newJWT)
case .failure:
self?.clearSession()
}
}
}Simulator Limitations
PushKit and CallKit are unavailable on the iOS Simulator. The SDK provides a WebSocket invite mode for development:
let config = VGClientInitConfig(loggingLevel: .info)
#if targetEnvironment(simulator)
config.enableWebsocketInvites = true // receive invites over WebSocket — no push needed
#endif
let client = VGVoiceClient(config)func answerCall(callId: String) {
#if targetEnvironment(simulator)
client.answer(callId) { error in
print("Answer: \(error?.localizedDescription ?? "OK")")
}
#else
guard let uuid = activeCallUUID else { return }
let action = CXAnswerCallAction(call: uuid)
callController.request(CXTransaction(action: action)) { _ in }
#endif
}WARNING
Always test the full push path on a physical device before release. The simulator cannot exercise the killed-app wakeup, session restoration under a real background budget, or CallKit lock-screen UI.
Pitfalls and Anti-patterns
1 - Calling PushKit completion before reportNewIncomingCall
Don't
func pushRegistry(_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void) {
client.processCallInvitePushData(payload.dictionaryPayload)
completion() // WRONG — called before reportNewIncomingCall
}Do
Store the completion and invoke it exclusively inside reportNewIncomingCall's async callback.
2 - Skipping reportNewIncomingCall for invalid pushes
Don't
guard pushType == .incomingCall else {
completion() // WRONG — no call reported to CallKit
return
}Do
Report a dummy call and immediately end it, then call the completion:
guard pushType == .incomingCall else {
let fakeUUID = UUID()
callProvider.reportNewIncomingCall(with: fakeUUID, update: CXCallUpdate()) { _ in
self.callProvider.reportCall(with: fakeUUID, endedAt: .now, reason: .failed)
completion()
}
return
}3 - Awaiting session restoration before processing the push
Don't
restoreSessionIfNeeded { // Waits for network...
self.client.processCallInvitePushData(payload) // Too late
}Do
Fire both concurrently:
restoreSessionIfNeeded() // Non-blocking
client.processCallInvitePushData(payload.dictionaryPayload) // Immediate4 - Fetching a JWT from your backend on push receipt
Don't
func handleIncomingPush(payload: PKPushPayload, completion: @escaping () -> Void) {
fetchJWTFromServer { jwt in // 500ms - 3s network call
self.client.createSession(jwt) // Another network call
self.client.processCallInvitePushData(payload.dictionaryPayload)
}
}Do
Persist the JWT at login time. Use it directly on push receipt with zero backend calls.
5 - Hardcoding isSandbox: true
Don't
client.registerVoipToken(token, isSandbox: true) // Breaks TestFlightDo
#if DEBUG
let isSandbox = true
#else
let isSandbox = false
#endif
client.registerVoipToken(token, isSandbox: isSandbox)6 - Re-registering the push token on every session restore
Don't
func createSession(jwt: String) {
client.createSession(jwt) { _, _ in
self.registerVoipToken(self.storedToken) // Accumulates device entries
}
}Do
Register only on user login or when didUpdate pushCredentials fires (token rotation).
7 - Forgetting to unregister on logout
If you skip unregisterDeviceTokens(byDeviceId:) on logout, the device continues receiving VoIP pushes for that user indefinitely — including after a different user logs in.
8 - Not mapping all hangup reasons to CallKit
Every call termination path must call callProvider.reportCall(with:endedAt:reason:) with the correct CXCallEndedReason. Missing a path corrupts the system call log.
9 - Enabling audio outside CallKit callbacks
Don't
func answerButtonTapped() {
VGVoiceClient.enableAudio(AVAudioSession.sharedInstance()) // WRONG
client.answer(callId) { _ in }
}Do
Handle audio exclusively in the CallKit delegate:
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
VGVoiceClient.enableAudio(audioSession)
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
VGVoiceClient.disableAudio(audioSession)
}10 - Expired push certificates
VoIP certificates expire after one year. The failure mode is complete and silent. Renew proactively and verify push delivery immediately after uploading a new certificate.
Production Checklist
Use this checklist before shipping:
- [ ] Xcode capabilities configured — Push Notifications enabled; Background Modes: Voice over IP + Background processing checked
- [ ] VoIP push certificate uploaded for both environments — Sandbox for debug, Production for TestFlight/App Store
- [ ]
isSandboxflag uses#if DEBUG— never hardcoded - [ ] JWT and refresh token persisted locally at login — session restoration must not require a backend call
- [ ]
reportNewIncomingCallcalled for every VoIP push without exception — including invalid, duplicate, or non-Vonage pushes - [ ] PushKit completion called only inside
reportNewIncomingCall's callback — never before, never skipped - [ ] Session restoration and push processing are concurrent —
restoreSessionIfNeeded()andprocessCallInvitePushData()fire independently - [ ] Token registration only on user login or token rotation — not on every
createSession - [ ]
unregisterDeviceTokenscalled on explicit logout - [ ] All call termination paths report to CallKit — remote hangup, cancel, timeout, local reject, media failure
- [ ] Audio enabled/disabled only in
didActivate/didDeactivate - [ ] CallKit code guarded with
#if !targetEnvironment(simulator)—enableWebsocketInvites = truefor simulator - [ ] Tested on physical device with app fully killed — exercises PushKit wakeup and session restoration race
- [ ] Tested on physical device with app backgrounded (suspended)
- [ ] Push certificate renewal date tracked — certificates expire after 1 year