Push setup (iOS & Android)
This guide gets your Expo / React Native app receiving poolse's pushes: call rings, chat notifications and missed calls. It follows the setup of Loop, poolse's reference app. Read Push notifications first for how the parts fit.
You will:
- Upload your app's push credentials on the dashboard.
- Add
@poolse/nativeand the right iOS capabilities to your app. - Register each device's tokens with poolse.
- Check it with a test push, and choose the notification settings.
1. Credentials
iOS: an APNs auth key
- In the Apple Developer portal, under Certificates, Identifiers &
Profiles → Keys, create a key with Apple Push Notifications
service (APNs) enabled. Download the
.p8file. Apple only lets you download it once. - Note the Key ID (10 characters) and your Team ID (10 characters, top right of the portal).
- On the poolse dashboard, open Push → Credentials → iOS. Enter:
- the Key ID and Team ID;
- the bundle id exactly as your app has it, such as
com.acme.chat. Do not add.voip; poolse adds it for VoIP pushes; - the environment: production for TestFlight and App Store builds, sandbox for development builds;
- the contents of the
.p8file.
The key is stored encrypted and is never shown again. To replace the other fields later, you can leave the key blank to keep the stored one.
Android: a Firebase service account
- In the Firebase console, open the project your app's
google-services.jsoncomes from. - Go to Project settings → Service accounts and click Generate
new private key. You get a JSON file with
type,project_id,client_emailandprivate_key. - On the dashboard, open Push → Credentials → Android and upload or paste that file. poolse checks the key with Google before saving it.
This is not google-services.json. That file goes in your app. The
service account stays with poolse.
2. The app
Install the packages. Expo prebuild needs Node 20.19 or later.
npx expo install @poolse/native expo-notifications
@poolse/native needs react, react-native, expo and
expo-notifications as peers.
app.json
{
"expo": {
"ios": {
"bundleIdentifier": "com.acme.chat",
"infoPlist": {
"NSMicrophoneUsageDescription": "Talk on voice and video calls.",
"NSCameraUsageDescription": "Show your camera on a video call.",
"UIBackgroundModes": ["audio", "voip", "remote-notification"]
},
"entitlements": { "aps-environment": "production" }
},
"android": {
"package": "com.acme.chat",
"googleServicesFile": "./google-services.json",
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.CAMERA",
"android.permission.BLUETOOTH"
]
},
"plugins": [
"expo-notifications",
["@poolse/native", { "ringback": "./assets/sounds/ringback.mp3" }]
]
}
}
- List
@poolse/nativeafterexpo-notifications. UIBackgroundModesneedsvoipfor PushKit,audioto keep a call going when the screen locks, andremote-notificationfor the read sync.aps-environmentmust match the environment of the tokens the build registers:developmentbuilds talk to sandbox. Loop keeps one value in its config and passes it both to the entitlement and toconfigure({ apnsEnvironment }), so the two cannot drift.ringbackis optional: the tone a caller hears while it rings. Its file name may only use lowercase letters, digits and underscores.
Plugin options:
| Option | Default | What it does |
|---|---|---|
ringback | none | A sound file bundled on both platforms. Pass its base name to configure({ ringback }). |
pictureInPicture | true | Android: a video call can float as picture-in-picture. |
lockscreen | true | Android: the app shows over the lock screen when opened for a call. |
Then rebuild the native projects:
npx expo prebuild --clean
@poolse/native is native code, so it does not run in Expo Go. Use a
development build.
Configure the native core
Call configure once, early, before anything else in
@poolse/native:
import * as calls from '@poolse/native';
await calls.configure('https://api.poolse.dev', {
ringback: 'ringback', // base name of the plugin's ringback file
apnsEnvironment: 'production', // iOS: must match aps-environment
});
3. Register the device
Register once a user is signed in, and again whenever the OS rotates a token. Registering is idempotent. Retry on failure: until a device is registered, nothing can ring it.
iOS
An iPhone needs two tokens.
import * as calls from '@poolse/native';
import * as Notifications from 'expo-notifications';
// The PushKit (VoIP) token — rings only. @poolse/native owns PushKit.
function registerVoip(voip: calls.VoipToken | null) {
if (!voip?.token) return;
void poolse.devices.register({
token: voip.token,
platform: 'ios',
environment: voip.environment === 'sandbox' ? 'sandbox' : 'production',
kind: 'voip',
});
}
registerVoip(calls.getVoipToken());
calls.onVoipToken(registerVoip);
// The regular APNs token — chat notifications, missed calls, read sync.
await Notifications.setNotificationCategoryAsync('poolse_missed_call', [
{ identifier: 'call_back', buttonTitle: 'Call back', options: { opensAppToForeground: true } },
]);
const perm = await Notifications.requestPermissionsAsync();
if (perm.granted) {
const { data: token } = await Notifications.getDevicePushTokenAsync();
await poolse.devices.register({
token,
platform: 'ios',
environment: 'production', // the same value as aps-environment
kind: 'alert',
});
}
Android
One token, from FCM:
const perm = await Notifications.requestPermissionsAsync();
if (perm.granted) {
const { data: token } = await Notifications.getDevicePushTokenAsync();
await poolse.devices.register({ token, platform: 'android' });
}
Notifications.addPushTokenListener(({ data }) => {
void poolse.devices.register({ token: data, platform: 'android' });
});
On Android 14 and later, showing an incoming call over the lock screen
needs the full-screen notifications permission, which is off by
default for many apps. Check it with calls.canUseFullScreenIntent()
and send the user to it with calls.openFullScreenIntentSettings().
Sign-out
Unregister on sign-out, or the phone keeps getting the previous user's notifications:
await poolse.devices.unregister(token);
Chat notifications on screen
Tell the core which conversation is open, so it does not notify for the chat the user is reading:
calls.setActiveConversation(conversationId); // opened a chat
calls.setActiveConversation(null); // left it, or app went to background
On iOS, let expo-notifications decide with the same rule:
Notifications.setNotificationHandler({
handleNotification: async (n) => {
const show = calls.shouldPresentMessage(n.request.content.data);
return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: show, shouldSetBadge: true };
},
});
Reply and Mark as read run in native code, with the app closed. On a
tap, find the conversation to open with
calls.conversationOfNotification(data). On Android, a tap on a missed
call arrives as calls.onCallAction(cb) / calls.takeCallAction(),
with type: 'open' | 'call_back'.
4. Test, then choose the settings
On the dashboard, Push → Overview shows whether each platform is connected and how many devices are registered. Send a test pushes to one user's devices and shows the result per device.
Then set the workspace's behaviour under Push → Notification settings:
- Send chat notifications: on or off.
- What a notification shows: sender and message, sender only, or nothing.
- Group conversations: every message, or only @mentions.
- Missed-call notifications: on or off.
Troubleshooting
| Symptom | Check |
|---|---|
| Nothing arrives on iOS | The bundle id on the dashboard. The aps-environment of the build vs. the environment registered. That both tokens are registered. |
| Calls ring only while the app is open | The VoIP token is registered with kind: "voip", and voip is in UIBackgroundModes. |
| Android shows a notification instead of a call screen on a locked phone | The full-screen notifications permission. |
| Users get two notifications per message | Your backend also sends notifications from message.created. Turn one off. |
| A signed-out user still gets notifications | Call poolse.devices.unregister(token) on sign-out. |