Skip to content

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

ComponentResponsibility
PushKitWakes the app from killed/suspended state, delivers push payload
Vonage SDKDecodes push payload, manages WebRTC session, handles call signaling
CallKitPresents system call UI, manages audio session activation
Your AppOrchestrates the above, manages JWT/session lifecycle

Project Setup

1 - Xcode Capabilities

Enable the following in your target's Signing & Capabilities tab:

text
Push Notifications             → enabled
Background Modes               → Voice over IP (checked)
                               → Background processing (checked)

Your entitlements file should contain:

xml
<?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

  1. In the Apple Developer PortalCertificates, Identifiers & Profiles, ensure your App ID has the Push Notifications capability enabled.
  2. Under Certificates, add an Apple Push Notification service SSL (Sandbox & Production) certificate, select your App ID, and upload the CSR.
  3. 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 → ExportPersonal Information Exchange (.p12). Leave the export password blank if possible (see the upload note below).

Upload via the Vonage Dashboard

  1. Log in to the Vonage Dashboard.
  2. Go to Applications → your application (the application_id your app's JWT authenticates against).
  3. Open the Enable push notifications tab and upload the .p12.
  4. 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:

swift
import PushKit
import CallKit
import VonageClientSDKVoice
import AVFoundation

4 - 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:

swift
let config = VGClientInitConfig(loggingLevel: .info)

#if targetEnvironment(simulator)
config.enableWebsocketInvites = true
#endif

let client = VGVoiceClient(config)
client.delegate = self

TIP

Request microphone permission early in the app lifecycle — ideally in application(_:didFinishLaunchingWithOptions:):

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

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

PriorityConditionActionTypical Latency
1stActive session existsReturn immediately — no work needed0 ms
2ndStored JWT is validclient.createSession(storedJWT)100-400 ms
3rdJWT expired, refresh token availableRefresh endpoint -> new JWT -> createSession300-1000 ms
4thNo credentialsLog warning; call cannot be answered0 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

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

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

swift
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

swift
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

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

swift
// 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.

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

swift
let config = VGClientInitConfig(loggingLevel: .info)

#if targetEnvironment(simulator)
config.enableWebsocketInvites = true   // receive invites over WebSocket — no push needed
#endif

let client = VGVoiceClient(config)
swift
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

swift
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

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

swift
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

swift
restoreSessionIfNeeded { // Waits for network...
    self.client.processCallInvitePushData(payload) // Too late
}

Do

Fire both concurrently:

swift
restoreSessionIfNeeded()                          // Non-blocking
client.processCallInvitePushData(payload.dictionaryPayload) // Immediate

4 - Fetching a JWT from your backend on push receipt

Don't

swift
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

swift
client.registerVoipToken(token, isSandbox: true)  // Breaks TestFlight

Do

swift
#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

swift
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

swift
func answerButtonTapped() {
    VGVoiceClient.enableAudio(AVAudioSession.sharedInstance()) // WRONG
    client.answer(callId) { _ in }
}

Do

Handle audio exclusively in the CallKit delegate:

swift
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
  • [ ] isSandbox flag uses #if DEBUG — never hardcoded
  • [ ] JWT and refresh token persisted locally at login — session restoration must not require a backend call
  • [ ] reportNewIncomingCall called 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 concurrentrestoreSessionIfNeeded() and processCallInvitePushData() fire independently
  • [ ] Token registration only on user login or token rotation — not on every createSession
  • [ ] unregisterDeviceTokens called 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 = true for 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

Further Reading

Built with VitePress.