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.

PartBuilding it yourselfWith poolse
VoIP pushA 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 callA 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 callAn 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 unlockingA 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 devicesRing 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.
MediaAn SFU and TURN servers to deploy, scale and secure, plus token issuance.LiveKit, operated by poolse. Media tokens are issued per call.
In-call experienceBackground audio, reconnects, earpiece, speaker and Bluetooth routing, picture-in-picture.Built into the core. Your UI renders one snapshot.
After the callMissed-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
  1. 1
    Maya’s phone
    calls.place(conversationId)
    One call from your JavaScript.
  2. 2
    poolse
    Rings everyone else in the conversation
    Every registered iPhone over PushKit, every Android over FCM.
  3. 3
    Jon’s locked iPhone
    Shows the incoming call screen
    The native core takes the push before React Native has even started.
  4. 4
    Jon
    Answers from the lock screen
    His other devices stop ringing. Maya’s screen moves to “connecting”.
  5. 5
    Both phones
    Talk, in the background if they like
    Mute, video, speaker, Bluetooth, picture-in-picture.
  6. 6
    poolse
    Writes it up when they hang up
    A 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. 1

    Upload your push keys

    Pushes go out under your app's name, from your credentials.

    dashboard.poolse.dev
    Dashboard → Push
      iOS      APNs key (.p8) · Key ID · Team ID · bundle id
      Android  Firebase service account (JSON)
  2. 2

    Install the native core

    The config plugin wires up CallKit, PushKit and the Android call service.

    terminal
    npx expo install @poolse/native expo-notifications
    app.json
    {
      "plugins": [
        "expo-notifications",
        ["@poolse/native", { "ringback": "./assets/ringback.mp3" }]
      ]
    }
  3. 3

    Configure once

    Subscribe to the call snapshot and register the device for rings.

    App.tsx
    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. 4

    Call

    Place a call on any conversation. The rest is buttons.

    CallButton.tsx
    // 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()
Web calls guide
Chat.tsx
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?

Yes, in a development build or a release build. @poolse/native is a native module with an Expo config plugin, so it is not available in Expo Go. Run npx expo install @poolse/native expo-notifications, add the plugin to app.json and run npx expo prebuild.

Will the phone ring when the app is closed or the phone is locked?

Yes. On iOS the ring arrives as a PushKit VoIP push and is shown by CallKit. On Android it arrives as a high-priority FCM message and is shown as a full-screen call notification. The user can answer from the lock screen, and the app opens already connected.

Do I need my own push server or media server?

No. poolse sends the VoIP and chat pushes with your own APNs key and Firebase service account, and carries call media through LiveKit, which poolse runs. You do not deploy TURN, an SFU or a push service.

What does Android 14 need for full-screen ringing?

Android 14 restricts full-screen intents to calling and alarm apps. @poolse/native exposes canUseFullScreenIntent() and openFullScreenIntentSettings() so your app can check and send the user to the setting; without it, Android shows the ring as a notification rather than full screen.

How many people can be on a call?

Up to 16, voice or video. Each conversation holds one live call at a time. Anyone in the call can ring more members of the conversation into it.

Can web users join calls from phones?

Yes. In the browser, @poolse/react and @poolse/react-ui give each conversation a room that people drop into, and browsers and phones end up in the same call.

Can I build my own call screen?

Yes. The native core sends your JavaScript a single snapshot of the call (phase, participants, who is speaking or muted, audio route, camera state) and takes commands like answer, hangUp, setMuted, setSpeaker and setCamera. CallVideoView renders local and remote video.

What does calling cost?

Call minutes are included in every plan: 100 voice and 50 video minutes a month on Free, 10,000 and 2,000 on Pro ($59), 100,000 and 25,000 on Scale ($249). Minutes count per person on the call, and ringing is free.

What is not supported?

Calling phone numbers (PSTN) or SIP, recording, transcription, and screen sharing through the SDK.

Read the full ringing-calls guide

Ship chat and calling this week.

Free for your first 1,000 monthly active users, with call minutes to try it on real phones.