Skip to content

Instantly share code, notes, and snippets.

@Pieeer1
Last active October 8, 2025 19:41
Show Gist options
  • Save Pieeer1/34152312ca6269cda81fb68214863049 to your computer and use it in GitHub Desktop.
Save Pieeer1/34152312ca6269cda81fb68214863049 to your computer and use it in GitHub Desktop.
Expo Modules Voip Push Token

Introduction

This gist shows the very basic bare minimum to get a voip push token in expo 53.

Getting Started

  1. Navigate to your project root
  2. Run the following command: npx create-expo-module --local expo-voip-push-token
  3. Remove all of the View and Web based files, we will not be needing those.
  4. Replace the Relevant Files with the ones in the gist. Podfile, Gradle Files, and Manifests are not modified.
export interface VoipToken {
voipToken: string;
}
export type ExpoVoipPushTokenModuleEvents = {
onRegistration: (params: VoipToken) => void;
notification: (params: { payload: Record<string, any> }) => void;
onCallAccepted: (params: { callUUID: string }) => void;
onCallEnded: (params: { callUUID: string }) => void;
};
package expo.modules.voippushtoken
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.net.URL
class ExpoVoipPushTokenModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoVoipPushToken")
Events("onRegistration", "notification")
Function("registerVoipPushToken") {
// do nothing, as this is technically IOS only
}
}
}
import ExpoModulesCore
import Foundation
import PushKit
import CallKit
class VoipPushDelegate: NSObject, PKPushRegistryDelegate, CXProviderDelegate {
var onTokenReceived: ((String) -> Void)?
var onIncomingPush: ((PKPushPayload) -> Void)?
var onCallAccepted: ((UUID) -> Void)?
var onCallEnded: ((UUID) -> Void)?
var voipRegistrationToken: String?
var pushRegistry: PKPushRegistry?
private let callProvider: CXProvider
private let callController = CXCallController()
override init() {
let config = CXProviderConfiguration(localizedName: "App Name")
config.supportsVideo = true
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.generic]
pushRegistry = PKPushRegistry(queue: DispatchQueue.main)
callProvider = CXProvider(configuration: config)
super.init()
pushRegistry?.delegate = self
pushRegistry?.desiredPushTypes = [.voIP]
callProvider.setDelegate(self, queue: nil)
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
guard type == .voIP else { return }
let tokenData = pushCredentials.token
let tokenParts = tokenData.map { String(format: "%02x", $0) }
voipRegistrationToken = tokenParts.joined()
onTokenReceived?(voipRegistrationToken ?? "")
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
// CallKit expects us to call this immediately
let update = CXCallUpdate()
update.hasVideo = true
update.remoteHandle = CXHandle(
type: .generic,
value: (payload.dictionaryPayload["callerName"] as? String ?? "Unknown Caller")
)
let uuid = payload.dictionaryPayload["uuid"] as? String
callProvider.reportNewIncomingCall(with: UUID(uuidString: uuid ?? "") ?? UUID(), update: update) { error in
if let error = error {
print("Error reporting call: \(error)")
} else {
self.onIncomingPush?(payload)
}
completion()
}
}
func startCall(callUUID: String, handle: String, callerName: String) {
let callUUIDObj = UUID(uuidString: callUUID) ?? UUID()
let handle = CXHandle(type: .generic, value: handle)
let callAction = CXStartCallAction(call: callUUIDObj, handle: handle)
callAction.isVideo = true
let transaction = CXTransaction(action: callAction)
self.callController.request(transaction, completion: { error in})
}
func endCall(callUUID: String) {
let callUUIDObj = UUID(uuidString: callUUID) ?? UUID()
let action = CXEndCallAction(call: callUUIDObj)
let transaction = CXTransaction(action: action)
self.callController.request(transaction, completion: { error in})
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// Handle the action, e.g., start audio, setup media, etc.
let callUUID = action.callUUID
self.onCallAccepted?(callUUID)
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
let callUUID = action.callUUID
self.onCallEnded?(callUUID)
action.fulfill()
}
func providerDidReset(_ provider: CXProvider) {
// Handle provider reset if needed
}
}
public final class ExpoVoipPushTokenModule: Module {
private let delegate = VoipPushDelegate()
private func initializePushDelegate() {
delegate.onTokenReceived = { [weak self] token in
self?.sendEvent("onRegistration", ["voipToken": token])
}
delegate.onIncomingPush = { [weak self] payload in
let payloadDict = payload.dictionaryPayload
self?.sendEvent("notification", ["payload": payloadDict])
}
delegate.onCallAccepted = { [weak self] uuid in
self?.sendEvent("onCallAccepted", ["callUUID": uuid.uuidString])
}
delegate.onCallEnded = { [weak self] uuid in
self?.sendEvent("onCallEnded", ["callUUID": uuid.uuidString])
}
}
private func startCall(callUUID: String, handle: String, callerName: String) {
delegate.startCall(callUUID: callUUID, handle: handle, callerName: callerName)
}
private func endCall(callUUID: String) {
delegate.endCall(callUUID: callUUID)
}
public func definition() -> ModuleDefinition {
Name("ExpoVoipPushToken")
Events("onRegistration", "notification", "onCallAccepted", "onCallEnded")
OnCreate(){
self.initializePushDelegate()
}
Function("startCall") { (callUUID: String, handle: String, callerName: String) -> Void in
self.startCall(callUUID: callUUID, handle: handle, callerName: callerName)
}
Function("endCall") { (callUUID: String) -> Void in
self.endCall(callUUID: callUUID)
}
Function("registerVoipPushToken") {
self.sendEvent("onRegistration", ["voipToken": self.delegate.voipRegistrationToken ?? ""])
}
}
}
import { NativeModule, requireNativeModule } from 'expo';
import { ExpoVoipPushTokenModuleEvents } from './ExpoVoipPushToken.types';
declare class ExpoVoipPushTokenModule extends NativeModule<ExpoVoipPushTokenModuleEvents> {
registerVoipPushToken(): void;
startCall(callUUID: string, handle: string, callerName: string): void;
endCall(callUUID: string): void;
}
// This call loads the native module object from the JSI.
export default requireNativeModule<ExpoVoipPushTokenModule>('ExpoVoipPushToken');
// Reexport the native module. On web, it will be resolved to ExpoVoipPushTokenModule.web.ts
// and on native platforms to ExpoVoipPushTokenModule.ts
export { default } from './src/ExpoVoipPushTokenModule';
export * from './src/ExpoVoipPushToken.types';
@debugtheworldbot
Copy link

thank you!

@Rinshin-Jalal
Copy link

thank you!

did you get this working!

@Pieeer1
Copy link
Author

Pieeer1 commented Jul 24, 2025

thank you!

did you get this working!

It works when the app is in the foreground, I will update once I finish the background functionality, the AppDelegate modifications are complicated.

@debugtheworldbot
Copy link

I tried just adding reportNewIncomingCall ,it works also background/killed

  var provider: CXProvider?

  func pushRegistry(
    _ registry: PKPushRegistry,
    didReceiveIncomingPushWith payload: PKPushPayload,
    for type: PKPushType,
    completion: @escaping () -> Void
  ) {
    guard type == .voIP else {
      completion()
      return
    }
    
    setupProviderIfNeeded()

    // extract payload
    let payloadDict = payload.dictionaryPayload
    let callUUID = UUID()
    let handle = payloadDict["handle"] as? String ?? "Handle"
    let callerName = payloadDict["callerName"] as? String ?? "Unknown Caller"
    let hasVideo = payloadDict["hasVideo"] as? Bool ?? false
    
    // create Call Update
    let callUpdate = CXCallUpdate()
    callUpdate.remoteHandle = CXHandle(type: .generic, value: handle)
    callUpdate.localizedCallerName = callerName
    callUpdate.hasVideo = hasVideo
    
    // use current provider to reportNewIncomingCall
    self.provider?.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
      if let error = error {
        print("CallKit report error: \(error)")
      } else {
        print("CallKit report success: \(callUUID), caller: \(callerName)")
        self.currentCallUUID = callUUID
      }
      
      self.onIncomingPush?(payload)
      
      completion()
    }
  }

  // init provider with custom icon
  func setupProviderIfNeeded() {
      if provider == nil {
          let config = CXProviderConfiguration(localizedName: "Your APP")
          config.supportsVideo = true
          config.maximumCallGroups = 1
          config.maximumCallsPerCallGroup = 1
          config.supportedHandleTypes = [.generic, .phoneNumber]
          
          // set custom icon at callKit page(optional)
          if let iconImage = UIImage(named: "CallKitIcon") {
              config.iconTemplateImageData = iconImage.pngData()
          }           

          let p = CXProvider(configuration: config)
          p.setDelegate(self, queue: nil)
          self.provider = p
      }
  }

@Rinshin-Jalal
Copy link

For my case i tried checking how expo-notifications works and found this "https://docs.expo.dev/modules/appdelegate-subscribers/"

Added a appdelegate like this It's all working now!

@Pieeer1
Copy link
Author

Pieeer1 commented Jul 26, 2025

Gist has been updated with the full closed app and background functionality. Good callout on the reporting in the module @debugtheworldbot

@mduc-dev
Copy link

We don't need to rewrite the call logic with CallKit here because we already have CallKeep, which handles this part well.

You can reuse CallKeep’s functionality by adding the dependency to your .podspec file like this:
s.dependency 'RNCallKeep'
Then, simply import it into your Swift file and use it as needed.

For more details, please refer to the images below.

image image

@Pieeer1
Copy link
Author

Pieeer1 commented Jul 29, 2025

Meh I was already at the dependency rewrite anyways since the voip push one wasn’t working so that’s why I did it. I will look into that to avoid reinventing the wheel though

@Pieeer1
Copy link
Author

Pieeer1 commented Jul 29, 2025

I also have some custom UUID logic I needed to implement so that was another reason for my specific rewrite, may not cover everyone’s specific use case.

@HERYORDEJY
Copy link

@Pieeer1
After integrating this custom module, my app crashes immediately after starting up. My Expo version is "~52.0.38".
What could be the cause?
And what is the possible solution?

Thanks.

@Pieeer1
Copy link
Author

Pieeer1 commented Sep 26, 2025

@Pieeer1 After integrating this custom module, my app crashes immediately after starting up. My Expo version is "~52.0.38". What could be the cause? And what is the possible solution?

Thanks.

I am going to need more information on that, but a common issue I was running into was expiring the VoIP token, which throws a panic.

@HERYORDEJY
Copy link

@Pieeer1 After integrating this custom module, my app crashes immediately after starting up. My Expo version is "~52.0.38". What could be the cause? And what is the possible solution?
Thanks.

I am going to need more information on that, but a common issue I was running into was expiring the VoIP token, which throws a panic.

@Pieeer1
What other information do you need?

@HERYORDEJY
Copy link

HERYORDEJY commented Sep 26, 2025

@Pieeer1
i got this from Xcode log;

[RNVoipPushNotificationManager] voipRegistration enter *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[EXExpoAppDelegate pushRegistry:didUpdatePushCredentials:forType:]: unrecognized selector sent to instance 0x13e671130' *** First throw call stack: (0x18fb400c0 0x18cfd9abc 0x18fbaa4c0 0x1934c5ef4 0x18fa548bc 0x18fa54940 0x21eb14448 0x102c88584 0x102ca2064 0x102cc2f98 0x102c98548 0x102c98484 0x18fa92c30 0x18fa36394 0x18fa37adc 0x1dc85d454 0x192459274 0x192424a28 0x1107a4a88 0x1b64c9f08)

terminating due to uncaught exception of type NSException *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[EXExpoAppDelegate pushRegistry:didUpdatePushCredentials:forType:]: unrecognized selector sent to instance 0x13e671130' *** First throw call stack: (0x18fb400c0 0x18cfd9abc 0x18fbaa4c0 0x1934c5ef4 0x18fa548bc 0x18fa54940 0x21eb14448 0x102c88584 0x102ca2064 0x102cc2f98 0x102c98548 0x102c98484 0x18fa92c30 0x18fa36394 0x18fa37adc 0x1dc85d454 0x192459274 0x192424a28 0x1107a4a88 0x1b64c9f08)

@Pieeer1
Copy link
Author

Pieeer1 commented Sep 26, 2025

Not too sure, I am not running into that issue. I would make sure you have all the delegates registered in your app config as an invalid argument on the voip registration is screaming permission issue to me,

@Pieeer1
Copy link
Author

Pieeer1 commented Oct 2, 2025

are you two running in a simulator or a physical device?

@VladChernyak
Copy link

@Pieeer1
For some reason, the event listeners in my layout component aren’t working as expected.
I can successfully get a VoIP token when authorizing with ExpoVoipPushToken.registerVoipPushToken() and ExpoVoipPushToken.addListener("onRegistration", () => {...}).

However, the event listener ExpoVoipPushToken.addListener("notification", () => {...}) that I call inside useEffect in my layout component doesn’t fire — even though the VoIP push definitely arrives (I can see the incoming call).

Any idea what might be causing this?

@Pieeer1
Copy link
Author

Pieeer1 commented Oct 8, 2025

@Pieeer1 For some reason, the event listeners in my layout component aren’t working as expected. I can successfully get a VoIP token when authorizing with ExpoVoipPushToken.registerVoipPushToken() and ExpoVoipPushToken.addListener("onRegistration", () => {...}).

However, the event listener ExpoVoipPushToken.addListener("notification", () => {...}) that I call inside useEffect in my layout component doesn’t fire — even though the VoIP push definitely arrives (I can see the incoming call).

Any idea what might be causing this?

is it failing to push entirely or are you getting any error messages? what device are you using?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment