VoIP calls Mobile
This guide is exclusively for Mobile (React Native) applications.
Your app can ring like the phone app: an incoming call shows up as a native call, full-screen on a locked phone or as a call notification while the phone is in use, even when the app is backgrounded or killed. A push notification wakes the app, and answering the call has it join a Fishjam room, where the call takes place.
Each platform uses its own transport:
- iOS: a VoIP push over APNs wakes the app through PushKit, and the SDK reports the call to CallKit.
- Android: a high-priority data message over FCM wakes the app, and the SDK surfaces the call through Telecom.
PushKit, CallKit, and Telecom are the operating systems' own native VoIP frameworks. The SDK drives them for you, so a single JS API works on both platforms. Registering calls with the OS also gets you remote surface control for free: the user can handle a call from a Bluetooth headset or Android Auto, and those actions reach your app as the same state changes described in this guide.
Everything in this guide is wired together in the voip-call example, whose README covers running it, including the APNs and FCM push credentials you must generate yourself.
Delivering a push notification is your backend's job: it sends a VoIP push via APNs (iOS) or a high-priority data message via FCM (Android) to the device token your app reports. This guide covers only the app side: wiring, native config, and the JS call flow. For a working sender, see our voip-call example server.
Wiring up the sending credentials is backend work too. For iOS, connect to APNs with a certificate-based or token-based connection; for Android, authorize your server to send via FCM.
How a call flows
- Your backend sends a VoIP push to the callee's device.
- The OS wakes the app, even if it was killed, and the SDK reports the call to
CallKit / Telecom.
callStatusbecomesincoming. - The user answers on the system call UI.
callStatusbecomesconnecting. - Your app joins the Fishjam room and waits for the remote media.
- Once media is live, your app calls
reportConnected(). The SDK confirms the connection to the OS, the call timer starts, andcallStatusbecomesactive.
The SDK tracks the call's state and drives the native call UI; joining the room and
reporting back is your app's job. Step 5 has a deadline: an answered call that
isn't reported connected within voip.fulfillAnswerCallTimeout (10s by default) is
ended by the OS.
1. Install the SDK
Install the React Native SDK as described in the Installation guide. The exact packages differ between Expo and bare React Native.
On Expo, also install @fishjam-cloud/ios-expo-voip
(iOS only). It registers PushKit at launch; without that registration the app never
receives VoIP pushes at all:
npx expo install @fishjam-cloud/ios-expo-voip
2. Configure the native project
- Expo
- Bare workflow
Configure the Fishjam config plugin in your app's app.json: turn on Android VoIP and
the iOS VoIP background mode, and add the voip block, which wires up the rest of the
iOS setup and (optionally) tunes the timeouts:
{ "expo": { "plugins": [ [ "@fishjam-cloud/react-native-client", { "android": { "enableVoIP": true }, "ios": { "enableVoIPBackgroundMode": true }, "voip": { "incomingCallTimeout": 15, "outgoingCallTimeout": 20, "fulfillAnswerCallTimeout": 5 } } ] ] } }
The three options do different jobs, and VoIP calls need all three:
android.enableVoIPturns on the Android half — the call permissions, the SDK's call components, and the FCM messaging service.ios.enableVoIPBackgroundModeadds thevoipbackground mode. iOS only delivers PushKit pushes to an app that declares it, so calls don't work without it.- The
voipblock wires up@fishjam-cloud/ios-expo-voipand puts calls in the Phone app's Recents with redial. What matters is that it is present — every value inside it is optional, so"voip": {}is enough to take the defaults: 45s incoming ring, 60s unconnected outgoing, and 10s for the answer handshake.
Because the block is what turns on the iOS wiring, setting it without
@fishjam-cloud/ios-expo-voip installed fails expo prebuild outright, with
Fishjam VoIP options are enabled but @fishjam-cloud/ios-expo-voip is not installed.
iOS push entitlement. iOS only issues a VoIP token to an app that declares
the aps-environment
push entitlement, so add it in app.json:
{ "expo": { "ios": { "entitlements": { "aps-environment": "development" } } } }
Use "production" for TestFlight / App Store builds. For the underlying iOS
frameworks, see Apple's PushKit
(VoIP pushes) and CallKit.
Android Firebase setup. VoIP on Android is delivered over
Firebase Cloud Messaging. Point
Expo at your google-services.json so Firebase is wired into the build:
{ "expo": { "android": { "package": "io.example.myapp", "googleServicesFile": "./google-services.json" } } }
Download google-services.json from the Firebase console
and save it at your project
root. Its package_name must match android.package exactly. See
Add Firebase to your Android project
for the full Firebase setup, and Android's
Telecom framework for
the call APIs.
enableVoIP: true and googleServicesFile must both be set. If enableVoIP
is on but googleServicesFile is missing, expo prebuild succeeds yet
Firebase is never wired up and pushes silently never arrive. If Android calls
don't ring, check this first.
Config plugins only run during expo prebuild. In a bare project you own the
native directories, so apply the changes by hand.
iOS
Register PushKit at launch in
AppDelegate.swift. A push can wake the app before any JS runs, so this must happen
at launch:
// in application(_:didFinishLaunchingWithOptions:) VoIPManager.registerForVoIPPushes()
Expose the pod's Objective-C class to Swift through the target's
bridging header.
An app with a Swift AppDelegate already has one at ios/<app>/<app>-Bridging-Header.h;
add the import to it:
#import "VoIPManager.h"
If "VoIPManager.h" doesn't resolve, use the pod-qualified form
#import <FishjamReactNativeWebrtc/VoIPManager.h>.
Add the push entitlement (aps-environment)
and the VoIP background mode. In Xcode → Signing & Capabilities these are
Push Notifications and Background Modes → Voice over IP:
<key>UIBackgroundModes</key> <array> <string>voip</string> </array> <key>NSMicrophoneUsageDescription</key> <string>Allow $(PRODUCT_NAME) to access your microphone.</string>
Adding the Push Notifications capability writes the entitlement for you; if you edit the file directly it must contain:
<key>aps-environment</key> <string>development</string>
Use production for TestFlight / App Store builds.
Android
Apply the google-services
Gradle plugin to the application module and place google-services.json at
android/app/google-services.json:
buildscript { dependencies { classpath 'com.google.gms:google-services:4.4.1' } }
apply plugin: 'com.google.gms.google-services'
Then declare the SDK's VoIP components in AndroidManifest.xml:
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/> <uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/> <application> <service android:name="com.oney.WebRTCModule.foregroundService.WebRTCForegroundService" android:foregroundServiceType="camera|microphone" android:stopWithTask="true"/> <activity android:name="com.oney.WebRTCModule.voip.IncomingCallActivity" android:exported="false" android:showWhenLocked="true" android:turnScreenOn="true" android:launchMode="singleInstance" android:excludeFromRecents="true" android:taskAffinity="" android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"/> <receiver android:name="com.oney.WebRTCModule.voip.EndCallNotificationReceiver" android:exported="false"/> <service android:name="com.oney.WebRTCModule.voip.PushNotificationService" android:exported="false"> <intent-filter android:priority="1"> <action android:name="com.google.firebase.MESSAGING_EVENT"/> </intent-filter> </service> <meta-data android:name="firebase_messaging_installation_id_enabled" android:value="true"/> </application>
The firebase_messaging_installation_id_enabled flag is what makes FCM register the app
instance and hand back the push token.
Timeouts (bare only). The voip timeouts the Expo tab configures are plain native
values, so set them directly. On iOS add integers to Info.plist; on Android add the
same names as <meta-data>, both in seconds:
<key>VoIPIncomingCallTimeout</key><integer>15</integer> <key>VoIPOutgoingCallTimeout</key><integer>20</integer> <key>VoIPFulfillAnswerTimeout</key><integer>5</integer>
Android delivers every FCM message to a single messaging service. The SDK's
service claims that slot, so an app that also uses expo-notifications or
@react-native-firebase/messaging would otherwise stop receiving its other pushes.
Name that library so the SDK relays non-VoIP messages to it:
[ "@fishjam-cloud/react-native-client", { "android": { "enableVoIP": true, "voipFallbackMessagingService": "expo-notifications" } } ]
Accepted values are "expo-notifications", "@react-native-firebase/messaging",
or a fully-qualified FirebaseMessagingService class name. iOS is unaffected: VoIP
pushes ride PushKit's separate channel.
If you'd rather own the messaging service yourself, set
"voipMessagingService": false instead. The SDK then declares no service at all, and
you forward calls to it from your own dispatcher with
PushNotificationService.handleVoIPMessage(context, message) and
PushNotificationService.handleNewToken(token).
A call still needs the camera and microphone. Configure those as in the
Installation guide, and request
them at runtime with useMicrophonePermissions
/ useCameraPermissions (see
Managing devices). On
Android 13+ you must additionally request POST_NOTIFICATIONS, or the
incoming-call notification can't be posted:
async functionrequestNotificationPermission () { if (Platform .OS === "android" &&Number (Platform .Version ) >= 33) { awaitPermissionsAndroid .request (PermissionsAndroid .PERMISSIONS .POST_NOTIFICATIONS , ); } }
3. Mount the VoIP provider
VoIPProvider tracks the call's state from
the CallKit / Telecom events and exposes it through
useVoIP. It doesn't touch the connection: joining
rooms, peer tokens, and media stay in your app (that's step 5).
Mount it alongside FishjamProvider. The
order doesn't matter to VoIPProvider, but the hook that bridges the two (step 5) calls
both useVoIP and the Fishjam connection hooks, so it must sit inside both:
functionRoot () { return ( <FishjamProvider fishjamId ={fishjamId }> <VoIPProvider isVideo > <App /> </VoIPProvider > </FishjamProvider > ); }
VoIPProviderProps accepts:
| Prop | Type | Required | Purpose |
|---|---|---|---|
isVideo | boolean | — | Marks outgoing calls as video, which registers the CallKit / Telecom session as video and labels the Android call notification. Incoming calls take their label from the push payload's isVideo instead. Make sure the room type matches. Default false. |
onWaitingCallDeclined | (payload: VoIPIncomingPayload) => void | — | A second, waiting call was declined from the native UI. Signal the caller; local state is unchanged. |
4. Report the device push token
useVoIP exposes the device's push token as
voipToken: the VoIP token from APNs on iOS, the FCM token on Android. Send it to your
backend; it's the push destination:
functionDeviceRegistration () { const {voipToken } =useVoIP ();useEffect (() => { if (!voipToken ) return; // Persist this token on your backend, keyed by the signed-in user.sendVoIPTokenToBackend (voipToken ,Platform .OS ); }, [voipToken ]); return null; }
A token is only valid with the service that issued it, so record each device's platform at registration time and route through APNs or FCM accordingly. Both platforms reissue tokens over time, so store the newest value per device rather than the first one you saw.
5. Connect calls to a room
This step wires the native call to the Fishjam room: VoIPProvider tells you a call
needs a room; your app joins it and reports back. Split the work like this:
The provider tells you (through useVoIP):
callStatusisconnecting: joincurrentCall.roomName(seeCurrentCall).callStatusleftconnecting/active: leave that room.isOnHoldorisMutedchanged: apply it to your tracks (step 9).
You tell the provider:
reportConnected()once media is live, orreportConnectFailed()if the join failed.endCall('remote')when the other side disappears from the room.'remote'means the other party hung up — it's one of sixCallEndedReasonvalues, all covered in step 8.
A remote peer appearing in the room is what "media is live" means, so watch
usePeers and report from there. Start your
microphone (and camera, for a video call) before joining, so your tracks are
published with the join; skip it and the call connects silent. Mint the peer token
however your backend does; the SDK never sees it:
functionuseVoIPRoomConnection () { const {callStatus ,currentCall ,reportConnected ,reportConnectFailed ,endCall , } =useVoIP (); const {joinRoom ,leaveRoom } =useConnection (); const {startMicrophone ,stopMicrophone } =useMicrophone (); const {remotePeers } =usePeers (); // Join the room the SDK is asking for; leave it when the call ends. consttargetRoom =callStatus === "connecting" ||callStatus === "active" ?currentCall ?.roomName :undefined ;useEffect (() => { if (!targetRoom ) return; letcancelled = false; (async () => { try { constpeerToken = awaitgetPeerToken (targetRoom ); if (cancelled ) return; awaitstartMicrophone (); // publish media with the join awaitjoinRoom ({peerToken }); } catch { if (!cancelled ) awaitreportConnectFailed (); } })(); return () => {cancelled = true; voidstopMicrophone (); voidleaveRoom (); }; }, [targetRoom ]); // A remote peer showing up means the call connected; it leaving means they hung up.useEffect (() => { if (callStatus === "connecting" &&remotePeers .length > 0) { voidreportConnected (); } else if (callStatus === "active" &&remotePeers .length === 0) { voidendCall ("remote"); } }, [callStatus ,remotePeers .length ]); }
Mount it once, anywhere inside both providers.
reportConnected() isn't optional; it fulfills CallKit's answer action and starts the
call timer. The native fulfil deadline (voip.fulfillAnswerCallTimeout, 10s by default)
covers your token fetch and room join, so be quick. Miss it and the OS ends the
call.
6. Handle an incoming call
A push wakes the app, the system UI starts ringing, and useVoIP moves callStatus to
'incoming' with currentCall populated.
Answering happens on the system call UI (the CallKit screen or Android's full-screen
activity), not from a button you build. On answer, callStatus moves to 'connecting' and
the hook from step 5 joins the room and reports back;
you don't wire the accept path yourself.
You can still render your own screen off callStatus while a call rings (to match your app's
look), and decline from it with endCall('rejected'):
functionCallUI () { const {callStatus ,currentCall ,endCall } =useVoIP (); // callStatus: 'available' | 'incoming' | 'connecting' | 'active' if (callStatus === "incoming") { return ( <IncomingCallScreen name ={currentCall ?.displayName }onReject ={() =>endCall ("rejected")} /> ); } return null; }
The push carries the call fields the SDK reads, as a
VoIPIncomingPayload:
| Field | Required | Purpose |
|---|---|---|
roomName | ✅ | The Fishjam room both parties join. Android drops a payload without it; iOS still rings, then throws in JS, so always send it. |
displayName | — | Label shown in the system call UI. Both platforms fall back to "Incoming call". |
handle | — | Stable id of the caller; falls back to displayName. This is what iOS Recents hands back for redial, so use a real user id, since a display name isn't unique. |
isVideo | — | Labels the incoming call as video; Android shows "Incoming video call" instead of "Incoming call". Defaults to false. |
avatarUrl | — | Android renders it in the incoming-call UI. iOS delivers it to JS only, since CallKit can't show caller images. |
On Android the push must be a data message carrying the discriminator
"fishjam": "voip-incoming", or the SDK won't treat it as a call. Send it with
high priority.
For the exact message our example server sends, see
main.ts.
7. Place an outgoing call
You mint the room name and ring the callee through your own signaling backend, then
call startCall(to, roomName). startCall only registers the call with CallKit /
Telecom and moves callStatus to 'connecting'. It doesn't ring anyone and doesn't join;
the hook from step 5 joins once callStatus is connecting,
exactly as it does for an incoming call. The order is yours to choose, and doing signaling
first means a failed ring never shows a call that can't connect:
functionusePlaceCall () { const {startCall } =useVoIP (); returnuseCallback ( async (to : string) => { constroomName =makeRoomName (); // you own the room name awaitringCallee (to ,roomName ); // your signaling rings the callee awaitstartCall (to ,roomName ); // registers the native call, callStatus → 'connecting' }, [startCall ], ); }
to is the callee's stable id, the value your backend looks up to decide who to ring. It
only affects the caller's own device, where the SDK uses it as both the handle and
the display name, so a raw user id shows up verbatim on their call screen. It never
reaches the callee: the name they see is the
displayName your backend puts in the push.
If startCall's native session won't start, the provider ends the call as failed and
callStatus returns to 'available'. A failed room join is reported by your hook's
reportConnectFailed() (step 5).
8. End a call
A call ends from several places: the system call UI, your room-connection hook (step 5) when the remote peer disappears, or your own in-call button. To hang up yourself:
const {endCall } =useVoIP (); awaitendCall (); // defaults to 'local'
endCall takes a CallEndedReason
saying how the call ended. Whatever ends the call (your endCall, the system UI, or your
hook passing 'remote' when the peer drops) sets lastEndedReason, always from your
device's point of view: whether you ended it ('local') or the other side did
('remote'), and why. Read it to react to a missed or rejected call, for example with a
"missed call" notification:
| Reason | Meaning |
|---|---|
local | This device hung up. Also covers a decline on iOS, which CallKit can't distinguish. |
rejected | The callee actively declined while ringing. Safe to pass on both, but CallKit has no case for it, so iOS ends the call exactly as local, and only Android reports it back distinctly. |
missed | Rang and was never answered, including a native ring timeout. |
remote | The other party hung up. |
answeredElsewhere | Picked up on another of the user's devices. |
failed | Setup failed: token fetch, room join, or the answer deadline. |
A push can't express a caller canceling or a callee rejecting, so map those across
devices over your own signaling channel: when the caller cancels while ringing, the
callee should end as missed; when the callee rejects, the caller should end as
rejected. Reserve remote for the other party hanging up a call that was already
connected.
9. Hold and mute
The OS holds your call when a cellular call arrives, and mirrors the system call UI's
mute button. useVoIP reports both as isOnHold and isMuted, but doesn't touch
your tracks. Applying them is your job, alongside the room connection from
step 5. Watch the flags and drive your own media hooks:
functionuseSyncMute () { const {callStatus ,isMuted } =useVoIP (); const {isMicrophoneOn ,toggleMicrophone } =useMicrophone ();useEffect (() => { if (callStatus !== "active") return; // only while a call is live // Mirror the system mute onto your published track. if (isMicrophoneOn ===isMuted ) voidtoggleMicrophone (); }, [isMuted ,callStatus ]); }
Hold works the same way: when isOnHold flips on, stop the mic and camera and remember
what was live; when it flips off, restore exactly that. To request a hold from your own
button (rather than react to the OS), call setCallHeld, then act on isOnHold flipping:
const {isOnHold ,setCallHeld } =useVoIP (); awaitsetCallHeld (true); // request a hold; isOnHold flips when the OS applies it
In-call A/V beyond this is the normal Fishjam device API (useMicrophone,
useCamera), exactly like any other Fishjam app.
See Managing devices for the full device API.
10. Redial from Recents (iOS)
Every call is recorded in the iOS Phone app's Recents, and tapping an entry reopens
your app. The tap arrives as pendingCallIntent, carrying only the handle to redial, never a
room. Mint a room and place the call exactly as in step 7,
then clearCallIntent() so it isn't replayed:
functionuseRedialFromRecents () { const {pendingCallIntent ,clearCallIntent } =useVoIP (); constplaceCall =usePlaceCall (); constisReady =useSessionReady ();useEffect (() => { if (!pendingCallIntent || !isReady ) return; const {handle } =pendingCallIntent ;clearCallIntent (); voidplaceCall (handle ); }, [pendingCallIntent ,isReady ,clearCallIntent ,placeCall ]); }
The SDK holds pendingCallIntent until you clear it, so an intent that arrives
before your app has restored its session isn't lost. Gate on your own readiness (as
isReady does here) and act on it once you can.
Enabling Recents itself is native setup:
- Expo
- Bare workflow
The voip block from step 2 is all this takes; there's
no option to enable. It sets the FishjamVoIPEnabled flag and the CallKit intent activity
types for you.
You own the Info.plist, so wire up the two halves the Expo plugin sets for you. First,
the plist: FishjamVoIPEnabled puts calls in Recents, and NSUserActivityTypes declares
that your app handles the "start call" intents
iOS hands back when someone taps a Recents entry. Without those types the tap opens your
app but no intent is delivered:
<key>FishjamVoIPEnabled</key> <true/> <key>NSUserActivityTypes</key> <array> <string>INStartCallIntent</string> <string>INStartAudioCallIntent</string> <string>INStartVideoCallIntent</string> </array>
Second, forward that intent to the SDK from your AppDelegate. The tap arrives as an
NSUserActivity;
handing it to VoIPManager is what turns it into the pendingCallIntent your JS reads
above. Do this before React Native's own Linking handler so it isn't consumed as a
deep link:
// in application(_:continue:restorationHandler:), before React Native Linking if VoIPManager.handleContinueUserActivity(userActivity) { return true }
11. Test it
Test on a real device: VoIP pushes never reach the iOS Simulator, and Android needs Google Play services for FCM. Then check the key paths:
- Send a push and confirm the native call screen appears, even when the app is killed.
- Answer, and the call connects with the OS timer starting once media is live.
- Let a call ring out and confirm it ends as
missed. - On iOS, the call shows in Recents and tapping it redials.
- Take a cellular call with Hold & Accept: your mic and camera pause, then resume.
Next steps
- Managing devices: control the camera and microphone during a call.