React Native VoIP calling
Voice and video calls that ring closed phones, for React Native and Expo.
@poolse/native is a native call core in Swift and Kotlin. It takes the VoIP push, shows CallKit or the Android call screen, answers from the lock screen and keeps the call alive in the background. poolse runs the push delivery, the ringing logic and the media servers.
What it takes to ring a phone, and what you skip
Phone-style calling in a React Native app is mostly native plumbing and servers. Here is the list, part by part.
| Part | Building it yourself | With poolse |
|---|---|---|
| VoIP push | A push service holding an APNs VoIP certificate or key and FCM credentials, sending high-priority pushes to every device of every callee. | poolse sends the rings with the APNs key and Firebase service account you upload. |
| iOS incoming call | A PushKit delegate that reports every VoIP push to CallKit immediately (iOS terminates apps that don’t), plus a CXProvider and audio session handling. | The Swift core in @poolse/native, running before React Native starts. |
| Android incoming call | An FCM service, a self-managed ConnectionService or call-style notification, a full-screen intent, and a foreground service with the phone-call and microphone types. | The Kotlin core in @poolse/native, with the config plugin adding the permissions. |
| Answering without unlocking | A way for a cold-started app with no session to authenticate the answer. | Every ring carries a grant bound to that call and device. |
| Ringing many devices | Ring timeouts, stopping the other devices on answer, declined, busy and missed states, call waiting. | Handled on the server and in the core, and recorded in call history. |
| Media | An SFU and TURN servers to deploy, scale and secure, plus token issuance. | LiveKit, operated by poolse. Media tokens are issued per call. |
| In-call experience | Background audio, reconnects, earpiece, speaker and Bluetooth routing, picture-in-picture. | Built into the core. Your UI renders one snapshot. |
| After the call | Missed-call notifications, a call log, webhooks to your backend. | A call log message in the chat, missed-call pushes with Call back, call.* webhooks. |
VoIP calling
Ringing a locked phone is the hard part. It's done.
Real phone-style calling needs VoIP pushes, CallKit, Android's call service, ring timeouts across devices, a media server and reconnect logic. With @poolse/native that is a native Swift and Kotlin core you install like any other package.
VoIP push, sent for you
Rings go out over PushKit on iPhone and high-priority FCM on Android, signed with your own keys. No push server to run.
The system call screen
CallKit on iOS and a full-screen call notification on Android, so a locked phone rings like a phone call.
Answer from the lock screen
Each ring carries a grant bound to that call and device, so answering needs no unlock and no signed-in session.
Every device, every outcome
Answer on one device and the others stop ringing. Declined, busy and missed are recorded, and call waiting is built in.
Media, hosted
Audio and video run through LiveKit. poolse issues the media tokens, so there is no SFU or TURN server for you to operate.
Calls that survive
Background audio, reconnects after a network drop, earpiece, speaker and Bluetooth routing, and picture-in-picture.
One call, start to finish
What happens between a tap on one phone and a ring on another, and everything poolse does after the call ends.
The call lifecycle- 1Maya’s phone
calls.place(conversationId)One call from your JavaScript. - 2poolseRings everyone else in the conversationEvery registered iPhone over PushKit, every Android over FCM.
- 3Jon’s locked iPhoneShows the incoming call screenThe native core takes the push before React Native has even started.
- 4JonAnswers from the lock screenHis other devices stop ringing. Maya’s screen moves to “connecting”.
- 5Both phonesTalk, in the background if they likeMute, video, speaker, Bluetooth, picture-in-picture.
- 6poolseWrites it up when they hang upA call log message in the chat, a call.finished webhook, minutes counted.
VoIP in four steps
From an Expo app with chat working to a phone that rings with the app closed. Loop, our reference app, is built exactly this way.
- 1
Upload your push keys
Pushes go out under your app's name, from your credentials.
Dashboard → Push iOS APNs key (.p8) · Key ID · Team ID · bundle id Android Firebase service account (JSON) - 2
Install the native core
The config plugin wires up CallKit, PushKit and the Android call service.
npx expo install @poolse/native expo-notifications{ "plugins": [ "expo-notifications", ["@poolse/native", { "ringback": "./assets/ringback.mp3" }] ] } - 3
Configure once
Subscribe to the call snapshot and register the device for rings.
import * as calls from '@poolse/native'; await calls.configure('https://api.poolse.dev', { ringback: 'ringback' }); // Your call screen is plain React, drawn from one snapshot. calls.onCallState((snapshot) => setCall(snapshot)); // Let poolse reach this iPhone with VoIP pushes. calls.onVoipToken((voip) => poolse.devices.register({ token: voip.token, platform: 'ios', kind: 'voip', })); - 4
Call
Place a call on any conversation. The rest is buttons.
// Rings every other member of the conversation. await calls.place(conversationId, { media: 'audio', // or 'video' sessionToken: await getToken(), }); calls.answer(); calls.hangUp(); calls.setMuted(true); calls.setSpeaker(true); calls.setCamera(true);
On the web
Drop-in rooms, right beside the chat.
In the browser, every conversation has a voice or video room that members drop into, like a huddle. Nobody's phone rings unless you ask it to. Phones and browsers join the same call, and the chat keeps going while you talk.
<RoomJoinBar>shows what's live, who's in it, and Join<RoomStage>is the video grid with mic, camera and output controls- Or build your own UI from
useRoom: participants, speaking, devices - Ring phones from the web with
poolse.rooms.ring()
import * as livekit from 'livekit-client';
import { useRoom } from '@poolse/react';
import { ConversationView, RoomJoinBar, RoomStage } from '@poolse/react-ui';
export function Chat({ id }: { id: string }) {
const room = useRoom(id, { livekit });
return (
<>
<RoomJoinBar room={room} />
{room.joined && <RoomStage room={room} title="Design review" />}
<ConversationView conversationId={id} />
</>
);
}React Native calling questions
Does it work with Expo?
Will the phone ring when the app is closed or the phone is locked?
Do I need my own push server or media server?
What does Android 14 need for full-screen ringing?
How many people can be on a call?
Can web users join calls from phones?
Can I build my own call screen?
What does calling cost?
What is not supported?
Ship chat and calling this week.
Free for your first 1,000 monthly active users, with call minutes to try it on real phones.