Skip to main content
The Push Notifications SDK’s VoIP package turns a CometChat call push into a ringing phone: on Android a call-style notification with Answer and Decline, a full-screen screen over the lock screen and the ringtone; on iOS a CallKit call. It works with the app in the foreground, in the background or not running at all. This page covers the push side of ringing. Starting, answering and ending calls with the Calls SDK is on the Ringing page.

Prerequisites

  • Calls SDK 5.1 set up and the user logged in.
  • Android: a Firebase project with your app, google-services.json in android/app/, and the Google Services plugin applied (Step 1). You don’t need @react-native-firebase.
  • iOS: an APNs provider with a token-based (.p8) key in the CometChat dashboard, and the Push Notifications capability plus the Voice over IP background mode enabled in Xcode.
  • Push notifications enabled in the CometChat dashboard with an FCM provider (Android) and an APNs provider (iOS), and those providers’ ids.
  • React Native 0.78 or later, minSdkVersion 24 on Android, and an iOS deployment target of 15.1 or later (what current React Native requires, whatever the packages’ podspecs allow). Some ring behaviours degrade below API 26, where Android gained notification channels and full-screen intents: on 24–25 the ring posts a plain notification instead of the channel-backed, full-screen one.

Step 1: Add the dependencies

package.json
Installing the VoIP package is the whole opt-in: the core package detects it and installs the ringer. The Chat SDK is optional for both packages; a calls-only app works.
The Calls SDK is a dependency of the VoIP package, not a peer: the package calls into it to pre-warm and leave sessions, and a version mismatch there is a silent failure rather than a build error.

Native setup

Android: apply the Google Services plugin. The push package already depends on firebase-messaging, so don’t add it yourself.
android/build.gradle
android/app/build.gradle
iOS: register for VoIP pushes before React Native starts, and hand the APNs token to the package. When a call wakes a killed app, iOS terminates the app unless the call reaches CallKit within a few seconds.
ios/YourApp/AppDelegate.swift
The iOS push guide has the full AppDelegate.

Step 2: Initialise once

Run this once per app start, as early as you can — the configuration is saved natively, so a push that wakes a cold process still finds it. There are two shapes, and which one you use matters. A calls-only app passes its configuration to the VoIP package and calls nothing else. Calling still needs a push token, and the core package is what registers it, so this initialises the core for you:
An app that also does chat calls the VoIP init() first and with no arguments, then initialises the core with its own chat settings:
This order is not a style choice — reversing it loses the cold-launch ring. A call that woke the process is reported to CallKit and emitted before JavaScript exists, so native buffers it. The core’s init() is what drains that buffer. Initialise the core first and the ring is delivered while nothing is subscribed to it yet, and it is gone: on iOS the app rings on CallKit but never prepares the session, so the caller is never told the callee is ringing.
CometChatVoipNotifications.init() must be the first thing you await on startup. Anything awaited before it — a stored-credentials read, a chat login, a remote config fetch — eats the cold-launch ring window.
Don’t pass a config to CometChatVoipNotifications.init() in a chat app. With a config it initialises the core for you, using only what you passed — so notificationSmallIcon, androidChannelId and every other chat-side setting is never set. Pass a config there only in a calls-only app, which calls nothing else.

Step 3: Register the background task

Android can send a decline while your app is fully killed. Register the background task at module scope in index.js, before AppRegistry.registerComponent:
index.js
Without it, a decline tapped on a killed app is never delivered and the caller rings on to unanswered.

Step 4: Firebase delegation (only if you own the transport)

You normally skip this step. The package ships its own Firebase messaging service and registers it in its manifest, so call pushes reach it without any code of yours. Declaring your own service in addition is what breaks delivery, not what fixes it. Delegate only if your app already owns a setBackgroundMessageHandler:
index.js
Pass sentTime and hand over every push. Firebase queues pushes while a device dozes; without the sent time a push delivered late looks fresh and rings for a call that is already over. And do not skip call pushes when the app is in the foreground: the push is the only signal that a call is coming in, in every app state.
Send call pushes at high FCM priority. Android lets a background app start a foreground service only with the temporary allowlist that a high-priority data message grants. At normal priority the call still rings, but the process can be killed between the ring and the answer.
More than one messaging service is not a conflict. Every app that includes Firebase declares FirebaseMessagingService at priority −500; what matters is the service Android actually resolves to. The SDK reports that in getDiagnostics() as competingMessagingService, which is null when all is well.

Step 5: Answer and join

On Answer the SDK stops the ring, brings your app to the front and emits onCallAccepted. It doesn’t ask for the microphone or camera; request them after sign-in (Step 6). There is nothing to “accept”.
PNCallInfo fields: sessionId, callerUid, callerName, callerAvatar, callType, receiverId, receiverType, expiresAtMs, isPeerCall, source (push for a call the package received, app for one handed over with showIncomingCall) and callAction. expiresAtMs is an absolute instant anchored to the caller’s own start, not a duration from now, so both sides stop ringing together however late the push arrived — read it rather than starting a timer of your own.
Every field arrives from Android as a string, including isPeerCall ("true" / "false") and expiresAtMs. Coerce before use.
A call answered before your JavaScript existed — the killed-app case this package is for — is buffered natively and replayed to the first onCallAccepted subscriber. Subscribing late is safe; not subscribing at all means the user answers and nothing happens.

Step 6: Permissions and ring readiness

On Android there is nothing to declare. POST_NOTIFICATIONS comes from the push package’s manifest, and RECORD_AUDIO, CAMERA and BLUETOOTH_CONNECT from the VoIP package’s; the manifest merger adds them to your app. The SDK requests the runtime prompts for you — CometChatPNHelper.requestNotificationPermission() and requestCallPermissions(). On iOS you do declare these, because Info.plist keys are your app’s own and nothing merges them in from a pod:
ios/YourApp/Info.plist
Everything else on Android (full-screen intent, foreground service of type phoneCall, MANAGE_OWN_CALLS, wake lock, vibrate, the ring activity and the ring service) comes from the packages’ own manifests too, and CallKit and PushKit are linked by the VoIP pod. See the Android permissions breakdown for which package declares what, and what a chat-only app gets.
A missing NSMicrophoneUsageDescription terminates the app mid-ring on iOS. It looks exactly like the ring expiring early. Check the device’s crash reports before treating a short ring as an SDK bug.
Microphone and camera — ask for these yourself. On React Native the package does not prompt when the user answers; it dismisses the ring and hands the call straight to your JavaScript. Call CometChatPNHelper.requestCallPermissions() once after sign-in, so the permission is already granted by the time a call arrives.
A user who declined the microphone answers into a call with no audio, and nothing re-asks. On Android 14+ the Calls SDK’s ongoing-call foreground service also needs RECORD_AUDIO. Check CometChatPNHelper.requestCallPermissions() succeeded before relying on calls, and ask again from your own call screen if it did not. The native Android SDK re-asks at the moment of answering; the React Native package does not yet.
Ring readiness. Several device settings silence a call with no error anywhere. Check once after sign-in and tell the user:
Diagnostics for a bug report. getDiagnostics() returns a redacted snapshot (whether a provider id exists, never what it is; never a token or payload): verifyPushSetup() registers the token and reports one of seven values: ok, notInitialised (no init() yet), notLoggedIn, notificationsDisabled (the OS permission is off, so nothing can be shown), tokenNotRegistered, testPushUnavailable (registration worked, the loopback test push is not available on the backend yet: a warning, not a failure) or testPushTimeout (the test push never arrived: delivery is broken). The token value is never logged, only its length and a short prefix.

How a ring ends

Every way out is handled by the SDK and needs no code in your app. Once the session is prepared, the Calls SDK reporting it ended also stops the ring immediately, without waiting for a terminal push.
An ongoing push is addressed to the CALLER, not the callee. Its sender is whoever performed the action — the callee who answered — so neither a sender nor a receiver comparison can tell you whether it concerns you. The SDK gates it on local ring state instead.

A call while in a call

Call waiting, the way the phone app does it. “In a call” means the Calls SDK is joined to a session, or a call answered here is still joining; a session that is only prepared while it rings does not count.
The SDK never decides on its own that the user is busy. A second call always rings; busy is sent only when the user taps Decline on it. The one thing the SDK does suppress is a call’s own push arriving back on the caller’s device — that is filtered out rather than rung, and nothing is sent to anyone.
End & accept waits for the new session’s prepare to settle, up to 5 seconds, before handing the call to your screen. Joining before the previous session has been released is what silently never completes.

Missed calls

Posted as “Missed call” / “Missed group call” with the caller’s name and call type, one per session, grouped under the SDK’s own summary so Android never folds them into your other notifications. Posted when: nobody answers in the ring window; a cancelled, unanswered or timed_out push ends a ring here; such a push arrives for a call that never rang here (the initiated push was lost or overtaken); an initiated push arrives already expired. Not posted when: answered here or on another device; declined (rejected or busy) here; ended; the user’s own outgoing call; logout while ringing. Turn missed calls off with showMissedCallNotification: false. This is a notification only; call history comes from Call Logs.

When the app is killed

This is the case the push exists for.
  1. The high-priority data push (Android) or the PushKit push (iOS) starts the app process.
  2. The native layer finds no in-memory configuration and restores it from disk.
  3. The ring runs on a foreground service of type phoneCall on Android, so the system keeps the process alive through the answer; on iOS CallKit holds the call. An alarm backs up the ring timeout.
  4. On Answer, the SDK launches your app and buffers the answered call until your JavaScript subscribes. It does not ask for the microphone here — request it after sign-in instead (see Step 6).
  5. A decline is sent through the background task, so it goes out even if the process dies right after the tap. A ring that simply times out sends nothing — the ring stops, a missed call is posted locally, and the caller’s unanswered comes from the backend’s own ring window.
Call CometChatVoipNotifications.init() as the first await in your app’s entry point — before reading stored credentials or restoring the session, not from them. It needs no login to wire up the ring; awaiting a credentials read first spends the cold-launch ring window, which is what the warning in step 2 is about. Restore the session straight afterwards, and the answered call is delivered once your JavaScript subscribes.
On iOS, APNs and VoIP pushes really are delivered to the Simulator and will cold-launch a killed app. Only CallKit’s own call presentation does not appear there, so test the ring on a device and the delivery path anywhere.

Staging or dedicated deployments

If your Chat and Calls SDKs point at a staging or dedicated host, give the Push SDK the same one. Otherwise the token is registered on the default production host, where your app id does not exist, and no push ever arrives.
The host override shapes differ between SDKs. The Chat SDK wants a bare host, the Calls SDK a full URL, and giving one the other’s shape hangs login forever rather than failing. The Calls SDK also has three base URLs — admin, client and calls — and setting only one splits call start and call end across environments.

Tokens

You do not register the push token. The SDK fetches it from Firebase or PushKit and registers it on login, under the Chat auth token when there is one and the Calls auth token otherwise. Unregistering is yours to call. Unlike the native Android and iOS SDKs, this package installs no logout listener, so nothing deletes the token on its own — see below. Unregister before the session goes away:
unregister() deletes the token from the backend and needs the logged-in credential, so after logout() there is nothing left to authenticate the delete, and the device goes on receiving the departed user’s call pushes.
The SDK keeps its ring identity in step with the Calls SDK’s login listener: logging in writes the uid, logging out clears it, and init() re-reads it to heal a login it missed. An app that logs the Chat SDK in and out without CometChatCalls.login / logout must call clearIdentity() itself.
In a calls-only app, logout() needs the Chat SDK — so use clearIdentity() and unregister() instead, and call init() again before the next login or the device stays silent.

Your own incoming-call screen

Subscribe to onCallRinging and draw your own UI. The SDK still owns the ringtone, the pre-warm, the timeout, the keep-alive and the decline queue; only the UI is yours.
Tell the SDK when your screen is up and down, so it does not also show its own and so it knows a ring is being presented:
Without onRingEnded, a cancelled call leaves your screen showing an Answer button for a call that no longer exists.
This replaces the in-app screen only. A push that arrives while your app is killed still rings with the SDK’s own native screen on Android and CallKit on iOS, because neither can be drawn from JavaScript that is not running.

Theming the ring

The native ring screen is drawn before any JavaScript exists — that is what lets a push ring a killed app — so the UI Kit’s theme cannot reach it. Android gives you two ways to colour it; on iOS the screen is CallKit’s and belongs to the system.

A theme, set from JavaScript (Android)

The theme is written to disk, because the screen it paints is drawn when a push wakes a killed process — there is no JavaScript running then to ask for colours. mode: "system" is read when the screen is drawn, so a call arriving after the user switches the device to dark is drawn dark without your app running. Set acceptIcon whenever you set a light acceptButton, or the white glyph disappears into it.

Colour resources (Android)

Every field above falls back to a cometchat_pn_call_* colour resource, which your app can replace at build time by declaring the same name — an app’s resource wins over a library’s. Use this when the colours never change at runtime, or to set a colour the theme leaves out.
android/app/src/main/res/values/colors.xml
The order is: the theme, then the resource, then the SDK’s own default. Every colour is optional at every level, so setting one does not mean restating the rest.

Strings and the call icon (Android)

Declared the same way, by name. This is also how you localise the ring.
  • Strings: cometchat_pn_incoming_call, cometchat_pn_call, cometchat_pn_unknown_caller, cometchat_pn_incoming_audio_call, cometchat_pn_incoming_video_call, cometchat_pn_missed_audio_call, and the other cometchat_pn_* strings.
  • Drawable: cometchat_pn_ic_call.
android/app/src/main/res/values/strings.xml

iOS

CallKit draws the incoming call and its appearance belongs to the system. callKitName, callKitIconName, callKitRingtone, callKitSupportsVideo and callKitIncludeInRecents on PNConfiguration are the whole surface; incomingCallTheme is ignored there. The native iOS SDK has the same limit — it is not a React Native one.
The colours above paint the full-screen ring. Android styles the call-style notification itself, so a heads-up ring on an unlocked device keeps the system’s colours.
If you want the in-app ring to match your brand exactly, draw it yourself with ringInForeground: false and your own component on onCallRinging — see Your own incoming-call screen. That covers a call arriving while your app is open; a push to a killed app still rings with the native screen, which is the point of it. The avatar is always the caller’s initials; the screen does not load the avatar URL. The ringtone is the device’s default on the incoming-call channel; because Android freezes a channel’s sound at creation, the SDK never changes it afterwards.

Manual mode

callMode: 'manual' makes the SDK parse, gate and forward only. It never rings on its own. The call reaches onCallRinging, and from there every step is yours:
In manual mode with nothing built, the call is silently lost: no notification, no ringtone, no screen, nothing sent to the caller, who rings out to unanswered. Use 'manual' only if you are rebuilding the whole ring.

Reporting a call you placed yourself

iOS only. An outgoing call you place yourself has to be reported to CallKit; nothing does it for you. Incoming calls get a CallKit report for free because PushKit forces one, but an outgoing call has to ask — and without it CallKit does not own the audio session, so a cellular call or another VoIP app can take the microphone and never give it back, the call never reaches Recents, and iOS cannot show it in the in-call status bar.
All three are no-ops on Android, which has no equivalent: an outgoing call there is ordinary app audio, with no system call UI and no Recents entry. They are declared on both platforms deliberately, so you can call them unconditionally without a Platform.OS check.
Pair every reportOutgoingCall with a reportCallEnded. CallKit otherwise believes the call is still running.

Knowing about a call in your own code

Listen on this SDK, not on the Calls SDK. On React Native the VoIP package is where an incoming call announces itself and its end; the Calls SDK has no incoming-call listener, because on this platform it never sees the call until you join one. It is the same callback whether the call arrived by push on a killed process or on a live one — the package buffers the ring natively and drains the buffer at init().
Each on… returns an unsubscribe function — call it when your screen unmounts, or every handler fires twice after the next mount. Subscribing late is safe: a ring or an answered call that arrived before your component mounted is held and delivered to the first subscriber, so the call that launched a killed app is not lost.
Coming from the Android or iOS guides? They tell you to listen on the Calls SDK with addIncomingCallListener. React Native has no such method — the split between the two packages puts incoming calls on the VoIP package instead, as above. The Calls SDK’s addEventListener is for a session you are already connected to, not for calls arriving.
See Listen for calls starting and ending for how these pair with the caller-side session events.

Troubleshooting