react-native-sinch-calling
v0.10.0
Published
React Native TurboModule wrapper for Sinch Voice Calling SDK (Android & iOS)
Downloads
2,006
Readme
react-native-sinch-calling
React Native TurboModule wrapper for the Sinch Voice Calling SDK (Android & iOS): app-to-app calls, PSTN calls, and Sinch conference rooms — voice only, no video.
Scope & architecture
This library wraps the native Sinch SDKs (SinchRTC on iOS, com.sinch:voice-video-android on Android) behind a single TurboModule. Incoming/outgoing calls always get reported to the OS call UI — CallKit on iOS, a self-managed ConnectionService on Android — so calls behave like a real phone call (lock-screen UI, works while backgrounded) without any extra code on your end.
The library never tries to guess what your backend's push payloads mean. By default it only recognizes Sinch's own push format (app-to-app calls, standard PSTN-to-registered-user routing). If your backend has its own "ring the app" signal — e.g. it parks an inbound PSTN call and pushes a custom payload telling the app to show a call screen before bridging into a Sinch conference — you opt into that explicitly (see Custom incoming-call flows below) rather than the library inferring it from your payload shape.
Prerequisites
Before wiring this into your app, you need:
- A Sinch account with an Application Key/Secret (Sinch Dashboard).
- A backend endpoint that mints a registration JWT. Sinch does not allow signing this JWT inside the app — the Application Secret must stay server-side. Your backend needs an endpoint (behind your existing auth) that:
- Identifies the calling user from your own session/auth token.
- Signs a JWT with:
header: { alg: "HS256", kid: "hkdfv1-YYYYMMDD" } // YYYYMMDD = current UTC date claims: { iss: "//rtc.sinch.com/applications/{applicationKey}", sub: "//rtc.sinch.com/applications/{applicationKey}/users/{userId}", iat, exp, nonce } signingKey = HMAC-SHA256(applicationSecret, "YYYYMMDD") - Returns the JWT to the app.
- A stable per-user identifier (
userId) — any string matching[A-Za-z0-9-_=]{1,255}. Use your own user id (e.g. a database id), not something that can change (email/phone). - Push credentials on the Sinch Dashboard: an APNs key/cert for VoIP push (iOS), and your Firebase Sender ID (Android) if you want calls to be delivered while the app is backgrounded.
Installation
npm install react-native-sinch-callingiOS
- Minimum deployment target: iOS 15.
- Run
pod installafter installing (pulls in theSinchRTCpod). - In your app's Info.plist:
<key>NSMicrophoneUsageDescription</key> <string>We need microphone access to make calls.</string> <key>UIBackgroundModes</key> <array> <string>audio</string> <string>voip</string> </array> - Enable the Push Notifications capability in Xcode (adds
aps-environmentto your entitlements) — required forenablePushNotifications(). - Strongly recommended: call
+[SinchCallingBootstrap eagerlyRegisterForVoipPush]as the first line ofapplication(_:didFinishLaunchingWithOptions:)in yourAppDelegate(native, not exposed to JS — see Registering for VoIP push before React Native boots). Registering only from JS viaenablePushNotifications()means a killed app has noPKPushRegistryset up yet at the moment iOS relaunches it to deliver a VoIP push, so it may fail to ring.
Android
- Minimum SDK: 24.
- Permissions and the
ConnectionServicedeclaration are already bundled in this library's manifest — you don't need to add anything for the base call flow. - If you want incoming calls while the app is backgrounded, wire your own FCM setup (e.g.
@react-native-firebase/messaging) and forward messages toregisterFcmPush/relayRemotePushNotification(see below).
Usage
import { SinchCalling } from 'react-native-sinch-calling';
// 1. Provide a way to fetch a registration JWT from your backend.
// Called automatically whenever the SDK (re)registers the user.
SinchCalling.setRegistrationCredentialsProvider(async () => {
const response = await fetch('https://your-api.example.com/sinch/registration-jwt', {
headers: { Authorization: `Bearer ${yourAppAccessToken}` },
});
const { jwt } = await response.json();
return jwt;
});
// 2. (Optional) Android: attach your FCM sender id + token before configure(),
// so incoming calls can be delivered while the app is backgrounded.
SinchCalling.registerFcmPush(fcmSenderId, fcmToken);
// 2b. (Optional) iOS: enable VoIP push via PushKit/SINManagedPush.
SinchCalling.enablePushNotifications(/* useProductionAps */ false);
// 3. Configure + start the client.
SinchCalling.configure({
appKey: 'YOUR_SINCH_APPLICATION_KEY',
environmentHost: 'ocra.api.sinch.com',
userId: currentUser.id,
});
SinchCalling.start();
// 4. Listen for lifecycle + call events.
SinchCalling.onClientStarted(() => console.log('registered'));
SinchCalling.onClientStartFailed(({ message, code }) => console.warn(message, code));
SinchCalling.onIncomingCall(({ callId, remoteUserId }) => { /* update UI */ });
SinchCalling.onCallProgressing(({ callId }) => { /* ringing/dialing */ });
SinchCalling.onCallEstablished(({ callId }) => { /* connected */ });
SinchCalling.onCallEnded(({ callId, endCause }) => { /* 'hungUp' | 'denied' | 'noAnswer' | 'error' | ... */ });
// 5. Place / control calls.
const callId = SinchCalling.callUser('other-user-id');
SinchCalling.answerCall(callId);
SinchCalling.hangupCall(callId);
SinchCalling.setMuted(true);
SinchCalling.setSpeakerEnabled(true);Calling a phone number or joining a conference
// PSTN: call a real phone number (E.164, e.g. "+14155550101").
// `callerId` is the caller-ID shown to the recipient — required on
// Android, ignored on iOS (derived from your application's provisioned
// voice number instead). Same caveat applies to `callConference` below.
const callId = SinchCalling.callPhoneNumber('+14155550101', '+14155550100');
// Join a Sinch conference room by id (e.g. one your backend created and
// bridged a PSTN leg into).
const callId = SinchCalling.callConference('conf_abc123', '+14155550100');
// Send DTMF tones on an active call (0-9, #, *, A-D).
SinchCalling.sendDTMF(callId, '5');Registering for VoIP push before React Native boots
SinchCalling.enablePushNotifications() (JS) only runs once the RN bundle has loaded, your app's state has rehydrated, and your code decides to call it — often well after your session/login logic runs. That's fine while the app is already running, but it's too late for the one case VoIP push exists for: iOS relaunching your app from killed purely to deliver a push. If PKPushRegistry isn't already registered by the time that happens, the call can't be reported and the launch is wasted.
On iOS, register natively instead, as the first line of application(_:didFinishLaunchingWithOptions:) — before React Native starts:
import SinchCalling
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
SinchCallingBootstrap.eagerlyRegisterForVoipPush()
// ... start React Native as usual
return true
}This is a native-only entry point (not exposed to JS) that registers PKPushRegistry and configures the same custom incoming/cancel-call push fields as configureCustomIncomingCallPush/configureCustomCancelCallPush (hardcoded to "callId"/"callerNumber" and "type"/"call_cancelled" — update it in SinchCalling.mm if your backend's payload uses different field names). It's safe to also still call the JS APIs later — they operate on the same shared native objects, so nothing gets double-registered.
Picks useProductionAps automatically from the build configuration (#if DEBUG) rather than a JS-supplied flag, since it runs before any JS exists.
Android is different in kind, not necessarily gap-free: FCM data messages delivered while the app is killed run through RN Firebase's headless JS task mechanism (setBackgroundMessageHandler), which starts a fresh JS engine/bridge specifically to handle that one message — independent of whether your app has "launched" in the normal sense. But that also means it's a fresh native module instance too: if configureCustomIncomingCallPush/configureCustomCancelCallPush are only called from your app's normal boot sequence (e.g. after checking a login token), that boot sequence may never run in this headless context, leaving those fields unconfigured for that invocation. If you see the same "doesn't ring when killed" symptom on Android, call configureCustomIncomingCallPush/configureCustomCancelCallPush directly inside your setBackgroundMessageHandler callback (before forwarding to relayRemotePushNotification), not only from your app's regular init path.
Custom incoming-call flows
Some backends don't let Sinch route calls directly to a registered client — e.g. an inbound PSTN call gets parked and bridged into a Sinch conference only after your backend decides to connect it (fraud checks, business hours routing, a "ring multiple agents" pattern, etc). For that, your backend sends your app its own push/socket signal, and you tell this library about it explicitly:
// Recognize your own backend's push payload shape (both platforms).
// idField/displayField are the keys your payload uses. Until this is
// called, every push is treated as a normal Sinch-relayed push.
SinchCalling.configureCustomIncomingCallPush('callId', 'callerNumber');
// A payload like { callId: 'abc', callerNumber: '+1415...' } arriving via
// relayRemotePushNotification (Android) or the native VoIP push handler
// (iOS) now reports a system call UI immediately instead of relaying to Sinch.
// You can also report a call UI directly, independent of any push — e.g.
// from a Socket.IO event received while the app is in the foreground:
SinchCalling.reportIncomingCallUI('abc', '+14155550101');
// Fires whenever a call UI is shown (from either path above) — a good
// place to kick off a caller-ID lookup and upgrade the display name.
SinchCalling.onIncomingCallUIShown(({ callId, displayName }) => {
lookupCallerName(displayName).then(name => {
SinchCalling.updateIncomingCallDisplayName(callId, name);
});
});
// When the rep answers/declines from the system call UI, call your own
// backend, then resolve or dismiss the call UI accordingly.
SinchCalling.onCallUIAnswered(async ({ callId }) => {
const { conferenceId, callerNumber } = await acceptOnYourBackend(callId);
const realCallId = SinchCalling.resolveCallUIToConference(callId, conferenceId, callerNumber);
if (!realCallId) {
// your backend accepted but joining failed — clean up the call UI
SinchCalling.dismissCallUI(callId);
}
});
SinchCalling.onCallUIDeclined(({ callId }) => {
declineOnYourBackend(callId);
});
// If your backend can also cancel/end a call that's already ringing on this
// device (caller hung up, answered on another device, forwarded elsewhere,
// etc.), it needs a way to tell a "cancel" push apart from a "new call" push
// — reusing the same `idField` for both (a natural design, since both need
// to say which call they're about) would otherwise make every cancel push
// look like a new incoming call. Configure the field/value your backend
// uses to mark a cancel, e.g. `{ type: "call_cancelled", callId, reason }`.
// The optional 3rd arg names the field carrying *why* it was cancelled —
// when it's `"FORWARDED"`, both platforms leave behind a distinct
// "call forwarded" notice instead of a plain "missed call" once the ringing
// call UI is torn down, so the rep isn't left wondering why the phone
// stopped ringing (a heads-up notification on Android, a local notification
// on iOS).
SinchCalling.configureCustomCancelCallPush('type', 'call_cancelled', 'reason');
// Fires once a cancel push has ended the system call UI natively — clean up
// any app-side state keyed by `callId` (a pending caller-ID lookup, etc.).
SinchCalling.onIncomingCallUICancelled(({ callId }) => {
cleanUpPendingCallState(callId);
});This is the one place iOS can't be fully "hands off": Apple requires every VoIP push to synchronously trigger a CallKit report from inside the native push handler, so the payload-shape check happens natively (using the field names you configured) rather than round-tripping through JS first.
Android: forwarding FCM pushes
Incoming calls arrive over the app's live connection to Sinch while it's in the foreground. For calls to arrive while backgrounded, forward FCM messages to the bridge from your own Firebase setup:
import messaging from '@react-native-firebase/messaging';
import { SinchCalling } from 'react-native-sinch-calling';
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
SinchCalling.relayRemotePushNotification(remoteMessage.data ?? {});
});
messaging().onMessage(async (remoteMessage) => {
SinchCalling.relayRemotePushNotification(remoteMessage.data ?? {});
});This only works while the app process is alive (backgrounded, not killed) — the JS runtime has to be running to receive the FCM callback. iOS doesn't have this limitation: VoIP push wakes the app process via PushKit even when fully killed.
Customizing the Android incoming-call notification
A PhoneAccount.CAPABILITY_SELF_MANAGED connection — what this library uses under the hood on Android — gets no system-drawn incoming-call screen the way iOS gets from CallKit; Android only tracks call/audio-focus state for it. So the library posts its own notification (title, Answer/Decline actions, a full-screen intent so it's visible over the lock screen). Customize or disable it with configureIncomingCallNotification:
import { SinchCalling } from 'react-native-sinch-calling';
SinchCalling.configureIncomingCallNotification({
title: 'Incoming call',
answerLabel: 'Answer',
declineLabel: 'Decline',
// Name of a drawable resource in your own app, resolved via
// `Resources.getIdentifier` — falls back to your app's launcher icon if
// omitted or not found.
smallIconName: 'ic_call_notification',
});Call this once during setup, before any call can ring. It's persisted natively (SharedPreferences), not just held in JS state — so it still applies to a call that arrives before JS has started again (e.g. the process was killed and only woken up to relay the push).
Already have your own incoming-call UI and don't want this notification at all? Opt out with enabled: false — the underlying self-managed connection (audio routing, Bluetooth, Android Auto, etc.) still registers either way, only the notification is skipped:
SinchCalling.configureIncomingCallNotification({ enabled: false });Tapping the notification body (or its full-screen intent when the device is locked) opens your app's own launcher activity with a callId extra — same as any other push notification tap — so your existing navigation/deep-link handling decides what screen to show. Tapping Answer/Decline, by contrast, is handled entirely at the native Telecom layer (via SinchTelecomManager, same as a Bluetooth headset button would be) and works even before your JS has started.
Android: keeping the call's microphone alive in the background
Android 14 (API 34) restricts a backgrounded app from continuing to capture microphone audio unless it's running a foreground service declared with the phoneCall/microphone types. This library starts one automatically the moment a call goes active and stops it the moment the call ends — nothing to call from JS. It shows its own low-priority "Call in progress" notification for as long as it runs, using the same icon/config as configureIncomingCallNotification. The manifest permissions it needs (FOREGROUND_SERVICE, FOREGROUND_SERVICE_PHONE_CALL, FOREGROUND_SERVICE_MICROPHONE) are declared in the library's own manifest and merge into your app automatically.
Android: the full-screen-intent permission can be silently revoked
USE_FULL_SCREEN_INTENT (what makes the incoming-call notification wake the lock screen) is auto-revoked by Android 14+ for apps the OS doesn't recognize as a "calling" app, as of a January 2025 Play policy change — silently, with no callback to your app. The notification still posts when this happens, it just never launches the full-screen intent, so a call arriving while the device is locked shows nothing until the user unlocks and pulls down the shade — a confusing "calls don't ring" report with no exception to point at.
Check for this (e.g. once at app launch, or whenever your app returns to the foreground) and prompt the user to re-grant it if needed:
import { SinchCalling } from 'react-native-sinch-calling';
if (!SinchCalling.isFullScreenIntentPermissionGranted()) {
// Show your own explanation first — this opens a system settings screen,
// which is disruptive without context.
SinchCalling.requestFullScreenIntentPermission();
}Both are no-ops that return true/do nothing on iOS and on Android below API 34, where the permission is granted at install time and can't be revoked this way.
Retrying after a failure
The SDK doesn't expose a distinct "reconnecting" callback — only onClientStartFailed (client-level) and onCallEnded with endCause: 'error' (call-level). Both events include a code (native error code) you can use to decide whether to retry:
SinchCalling.onClientStartFailed(({ code }) => {
// e.g. retry SinchCalling.start() with backoff for network-ish codes,
// surface a hard error to the user for anything else.
});API
| Method | Platforms | Notes |
| --- | --- | --- |
| configure({ appKey, environmentHost, userId }) | both | |
| start() / stop() | both | |
| setRegistrationCredentialsProvider(fn) | both | Answers the JWT challenge automatically |
| callUser(userId) → callId | both | App-to-app call |
| callPhoneNumber(phoneNumber, callerId) → callId | both | PSTN call. callerId is Android-only |
| callConference(conferenceId, callerId) → callId | both | Joins a Sinch conference room. callerId is Android-only |
| sendDTMF(callId, key) → boolean | both | key is one of [0-9, #, *, A-D] |
| answerCall(callId) / hangupCall(callId) | both | |
| setMuted(muted) / setSpeakerEnabled(enabled) | both | |
| registerFcmPush(senderId, token) | Android | Call before configure(). No-op on iOS |
| enablePushNotifications(useProductionAps) | iOS | No-op on Android |
| relayRemotePushNotification(payload) | Android | No-op on iOS |
| configureCustomIncomingCallPush(idField, displayField) | both | Opt-in — see Custom incoming-call flows |
| configureCustomCancelCallPush(typeField, cancelValue, reasonField?) | both | Opt-in — distinguishes a cancel/end push from a new-call push, see Custom incoming-call flows. reasonField drives a missed/forwarded notice on both platforms |
| reportIncomingCallUI(callId, displayName) | both | Shows a call UI for any reason you decide to |
| resolveCallUIToConference(callId, conferenceId, callerId) → callId | both | Turns a shown call UI into a real call |
| dismissCallUI(callId) | both | Cleans up a call UI that couldn't be resolved |
| updateIncomingCallDisplayName(callId, displayName) | both | Overrides the shown caller name |
| configureIncomingCallNotification({ enabled?, title?, answerLabel?, declineLabel?, smallIconName? }) | Android | Customizes — or disables — the incoming-call notification self-managed connections require on Android; see Customizing the Android incoming-call notification. No-op on iOS |
| isFullScreenIntentPermissionGranted() → boolean | Android | Always true on iOS/pre-14 Android; see The full-screen-intent permission can be silently revoked |
| requestFullScreenIntentPermission() | Android | Opens system settings to re-grant it. No-op on iOS/pre-14 Android |
Events: onClientStarted, onClientStartFailed, onRegistrationCredentialsRequired (internal — handled by setRegistrationCredentialsProvider), onIncomingCall, onCallProgressing, onCallEstablished, onCallEnded, onPushTokenRegistered, onPushTokenRegistrationFailed, onVoipPushTokenUpdated (iOS — forward to your backend), onIncomingCallUIShown, onCallUIAnswered, onCallUIDeclined, onIncomingCallUICancelled.
Known limitations
- If the app is fully killed (not just backgrounded), Android incoming calls won't arrive —
relayRemotePushNotificationneeds the JS runtime alive. iOS handles this correctly via PushKit. - CallKit/ConnectionService setup happens lazily when JS first requires the native module. For maximum reliability on a killed app, a production app should also register the VoIP push handler from the host app's
AppDelegate, which is outside the scope of a pure JS-facing library — see Sinch's own CallKit guide if you need this. - If a call is answered/ended from your own JS UI while the native CallKit/Telecom screen is also showing (e.g. app was foregrounded when the push arrived), the system call UI may not always dismiss in perfect sync.
configureCustomIncomingCallPushdetection on iOS happens synchronously inside the native VoIP push handler (an Apple requirement) — it can't wait on any JS logic, only the field names you configured up front.
Contributing
License
MIT
Made with create-react-native-library
