In-app calling
Calls with your agent that work like a WhatsApp call — the customer calls it from your app, or your agent rings the customer, answered from the lock screen and going on with the screen off. Everything an engineer needs, from the prerequisites to a working server.
A call with your agent can work like a WhatsApp call. The customer taps Call in your app — or your agent rings them, on the lock screen, with the system's own incoming-call screen — and the conversation carries on with the screen off, over Bluetooth, while they open other apps.
The iOS and Android SDKs do this with a native call engine: the call's audio
runs in your app's own process with the platform's echo canceller, so it rings
through CallKit and VoIP pushes on iOS and a full-screen call
notification on Android. React Native and Flutter apps use the same engine,
through @graineai/inapp-react-native-calls and
graine_inapp. You add a few lines to your app, and your server
sends one push when it wants the agent to call someone.
Two engines, one agent. The assistant bar inside your app runs on the in-app engine (WebRTC, components drawn on screen). A call runs on the native call engine. Both talk to the same agent, with the same screen context and actions, and only one runs at a time — which ends which. Calls are voice only.
How it works
The customer calls the agent
- The customer taps your call button; your app calls
startCall(reason:). - The SDK places the call — the system's call screen on iOS, the SDK's call
screen on Android — and connects to the agent. The agent hears
who_called = "customer"and the reason. - When it ends, the SDK reports the outcome to your app.
The agent calls the customer
- Your server decides to call (a payment is due, a delivery slipped, a callback the customer booked) and sends a push to the customer's devices — or, with hosted ringing, Graine rings them for a call the agent, the customer or your team booked.
- The SDK rings — CallKit on iOS, a call notification on Android — even when the app is closed or the phone is locked.
- The customer answers; the SDK connects to the agent with
who_called = "agent", the reason and any variables from the push. - The SDK reports every outcome to your app —
answered,declined,missed,endedorfailed— and your app tells your server.
The parts of an agent's call, numbered in order. A call the customer places starts at your app's startCall instead of a push. In depth: How calls work.
| Part | Who builds it |
|---|---|
| The ring, answering, the call screen, the lock screen, Bluetooth, the screen off | The SDK |
| The conversation, your screen context and actions, the record in Call History | The SDK and Graine |
| Who the customer is, and why you are calling | Your app — identify() and the call's reason |
| Deciding to call someone, and when | Your server — or, with hosted ringing, a booking: by the agent, the customer or your team |
| Sending the push | Your server — APNs on iOS, Firebase Cloud Messaging on Android. With hosted ringing, Graine, with your credentials |
| Storing devices, outcomes and the calls list | Your server. With hosted ringing, Graine |
There are two ways to ring. Your own push server: Graine holds no push tokens and rings nothing — your server knows your customers, their devices and when they agreed to be called, and it sends the push and keeps the record. Hosted ringing: you upload your APNs key and Firebase service account once, the SDK registers each device with Graine, and Graine rings it when a booked call is due and records every outcome. Tokens and credentials are stored encrypted; a dead token is deleted, and a device unseen for 90 days with it. Hosted ringing
How calls work
What runs where, each call step by step, its states, and how a ring reaches a locked phone.
Your calling server
What you store, ringing through APNs and FCM, keeping each call's record right, and when to call.
Hosted ringing
Graine rings your customers with your credentials — no server of yours.
Test and release
The test plan, what the App Store and Google Play ask for, and a go-live checklist.
Reference
Every method, option, state, outcome and push key, on all four platforms.
SDK reference
What startCall, outcomes and scheduled calls return, on every platform.
This page sets calls up, platform by platform.
Before you start
| What you need | |
|---|---|
| Graine | An agent for the app, its publishable key (Agent → Embed & Widgets), and app:// in its Allowed domains. A prompt that handles the variables who_called and call_reason (below) |
| iOS | SDK 0.7.0+, iOS 15+, an Apple Developer account, the Push Notifications capability, an APNs auth key (a .p8 file, its Key ID and your Team ID) for your server or for hosted ringing, and a real iPhone — CallKit and VoIP pushes do not run in the simulator |
| Android | SDK 0.7.0+, Android 7 (API 24)+, a Firebase project with Cloud Messaging added to the app (google-services.json), and a service account for your server |
| React Native | @graineai/inapp-react-native-calls 0.2.0+ with @graineai/inapp-react-native 0.41.0+, React Native 0.71+, a native build (not Expo Go), Firebase in the Android app for pushes (google-services.json; the package brings Firebase Cloud Messaging — below), and the iOS and Android requirements above |
| Flutter | graine_inapp 0.7.0+, Flutter 3.19+, Firebase Cloud Messaging in the Android app for pushes (google-services.json, its Gradle plugin and firebase-messaging), and the iOS and Android requirements above |
| Your server | Only when you ring through your own push server: anywhere you can make an HTTPS request from. The example below is Node.js 18+. With hosted ringing, none |
| WebRTC (optional) | For calls whose audio rides WebRTC, from 0.9.0: the iOS package GraineInAppRTC, the Android module ai.graine:inapp-android-rtc — each at the same version as its SDK — or, in React Native and Flutter, a build with WebRTC switched on. Without it every call runs on the WebSocket, as before |
The prompt. The SDK sets who_called on every call, and call_reason
whenever the call has a reason. A variable nothing sets is left out of the
prompt, so write it for both directions, and for a call with no reason:
iOS
Install — Xcode → File → Add Package Dependencies →
https://github.com/GraineAI/inapp-ios, version 0.7.0 or later, the
GraineInApp library.
Capabilities. Signing & Capabilities → Push Notifications, and
Background Modes → Audio, AirPlay, and Picture in Picture and Voice over
IP. Add the microphone reason to Info.plist:
Ask for the microphone before the first call — during onboarding, say, with
await calls.requestCallPermissions() — so that a call answered on the lock
screen has it. If any of this is missing, the SDK says so at
calls.configure: see Permissions and the setup check.
Set up at launch, every launch. iOS wakes a closed app for a VoIP push and
delivers it only once enableVoIPPushes has run, so this goes in
didFinishLaunching — not behind a sign-in or a screen:
Backend is your own networking code. After sign-in, tell the agent who the
customer is: GraineInApp.shared.identify(["name": user.firstName]).
Let the customer call, and show the call while your app is open — the system's call screen covers the lock screen and the background:
Ring the device from your server with the token from enableVoIPPushes —
see Your server. Answering connects the call; your
onCallOutcome hears answered, then ended.
For a screen of your own, GraineCalls is an ObservableObject: state
(idle, ringing, connecting, active, ended), callId, callerName,
muted, speakerOn, agentSpeaking, caption and connectedAt, with
answer(), end(), setMuted(_:) and setSpeaker(_:) (the earpiece by
default, like a phone call). The full list is in the
iOS reference.
Every VoIP push rings. iOS requires each VoIP push to be reported to
CallKit at once and stops delivering them to apps that do not — so the SDK
reports every one. A push that arrives during a call is ended at once and
reported missed; one that is not an agent call is ended at once too, and
reported failed if it carries a call_id. Send VoIP pushes only for calls.
An app with its own PKPushRegistry hands the payload to
calls.reportIncomingCall(payload:completion:) instead. Apple does not allow
CallKit in apps on the App Store in China: leave calls off there.
Calls over WebRTC (optional)
Add the GraineInAppRTC package — Xcode → File → Add Package Dependencies →
https://github.com/GraineAI/inapp-ios-rtc, the same version as GraineInApp,
its GraineInAppRTC library in your app's target (CocoaPods: pod 'GraineInAppRTC', :git => 'https://github.com/GraineAI/inapp-ios-rtc.git', :tag => '0.10.3') — and calls in both directions carry their audio over WebRTC.
No code, no capability: the calls find it by themselves. One Info.plist entry, for the App Store: NSCameraUsageDescription — WebRTC contains camera code, and App Store processing refuses a build that references the camera without it (ITMS-90683: the build never reaches TestFlight). Calls are audio only; say so, for example Calls are audio only. The camera is never turned on.
About 12 MB more installed. The choice, the dashboard switch and the fallback:
WebRTC or the WebSocket.
Android
Install — the SDK and Firebase Cloud Messaging:
Add Firebase to the app as Firebase's own setup describes (google-services.json
and its Gradle plugin). The SDK's manifest merges in everything a call needs —
the microphone, notifications, the full-screen ring, a microphone foreground
service, the call screen and its receiver, and a Firebase messaging service of
its own — so there is nothing to add for the SDK itself.
Set up in your Application:
Pushes reach the SDK by itself. With Firebase in the app, the SDK's own messaging service rings every call push — even when the app is closed — and the SDK reads the push token when it starts, so for hosted ringing it registers the device itself. No code.
Ringing from your own server, or already have your own
FirebaseMessagingService? Your server needs each device's token, so keep a
messaging service of your own. Android delivers each message to one service, and
yours outranks the SDK's: hand Graine's pushes and the token to the SDK first,
two lines:
Ask for the permissions with your onboarding, so a ring is never answered into a permission prompt. One call asks for the microphone and, on Android 13+, notifications — from a transparent screen of the SDK's own, so nothing forwards a result:
Let the customer call. startCall opens the call screen and connects:
Ring the device from your server with the FCM token — see Your server.
The call screen. GraineCallActivity rings (Answer, Decline) and then
shows the call (status, the line being said, mute, speaker, end), over the lock
screen. The call itself runs in a foreground service, so locking the phone or
opening another app does not end it; the ongoing notification has Hang up.
For your own screen, name it in GraineCallsConfig(callActivity = …) and draw it
from GraineInApp.get().calls (state, call, muted, speaker,
agentSpeaking, caption, connectedAt) and GraineCallListener
(onCallState, onCallCaption, onAgentSpeaking, onCallControls), with
answer(), decline(), end(), setMuted() and setSpeaker(). The SDK opens
your activity for a ring and for Answer, which gives it three more jobs:
- Show over the lock screen —
setShowWhenLocked(true)andsetTurnScreenOn(true)— as the SDK's own screen does. - Forward
onRequestPermissionsResulttoGraineInApp.get().calls: the SDK asks for the microphone from the call screen. - Pass new intents on if the activity is
singleToporsingleTask: callsetIntent(intent)inonNewIntent, because Answer can arrive as a new intent.
The full list is in the Android reference and the calling reference.
The full screen is a permission on Android 14 and later. Google Play grants
USE_FULL_SCREEN_INTENT by default only to apps whose main purpose is calling
or alarms. Without it the ring is a heads-up notification — still ringing,
with Answer and Decline. calls.callPermissions.fullScreen tells you which you
have, and calls.openCallSettings(GraineCallPermission.FULL_SCREEN) opens the
page where the customer allows it.
Calls over WebRTC (optional)
Add the WebRTC module beside the SDK, at the same version, and calls in both directions carry their audio over WebRTC:
Nothing else changes in your code. The module's manifest adds
ACCESS_NETWORK_STATE (a normal permission: nothing is asked) and its R8 rules
ship inside it. About 7.4 MB more installed on a 64-bit phone. The choice, the
dashboard switch and the fallback:
WebRTC or the WebSocket.
Permissions and the setup check
What the SDK does by itself: the call itself — CallKit and VoIP pushes on iOS; on Android the ring, the call screen and the microphone service, every manifest entry merged — the call going on with the screen off, the microphone asked for when a call starts without it, and every outcome reported.
What your app adds: the iOS capabilities and Info.plist entries (an SDK
cannot add them; with Expo, the React Native calls package's config plugin
does), Firebase in the app on Android (the SDK's own messaging service does the
rest), and one call to ask for the permissions at
a moment you choose — the SDK never asks on its own at launch:
| Ask | Read | Open Settings | |
|---|---|---|---|
| iOS | await calls.requestCallPermissions() | calls.callPermissions | calls.openCallSettings(.microphone) |
| Android | calls.requestCallPermissions(activity) { p -> } | calls.callPermissions | calls.openCallSettings(GraineCallPermission.FULL_SCREEN) |
| React Native | await GraineCalls.requestCallPermissions() | await GraineCalls.callPermissions() | GraineCalls.openCallSettings("fullScreen") |
| Flutter | await GraineCalls.instance.requestCallPermissions() | await GraineCalls.instance.callPermissions() | GraineCalls.instance.openCallSettings(GraineCallPermission.fullScreen) |
Each permission — microphone, notifications (Android), fullScreen
(Android 14+) — is granted, denied (not yet), blocked (only Settings can
change it) or notNeeded (iOS's notifications and full screen: CallKit).
What is still missing. At configure the SDK checks the setup — on iOS the
usage string, the audio and voip background modes, the push entitlement,
enableVoIPPushes and the VoIP token; on Android the manifest, the push token,
notifications and the full-screen ring; on both the microphone and whether
Graine can ring this phone — and logs each problem once with the exact fix.
callSetupProblems returns the same list as codes and sentences. Every code:
Permissions → What is still missing.
Your server
Three jobs: remember each customer's devices, ring them, and record what happened.
1. Store devices. Your app sends its token after launch (saveDevice
above): an iOS VoIP token or an Android FCM token, per customer and device.
2. Ring. Create a call record (call_id, the customer, the reason, status
ringing), then push to each of their devices. The push carries string values:
| Key | ||
|---|---|---|
type | required | agent_call, exactly |
call_id | required | Your id for this call — every outcome carries it back |
reason | optional | Becomes the prompt's call_reason |
caller_name | optional | What the ring shows, over the configured displayName |
variables | optional | A JSON object as a string: more prompt variables for this call. If it cannot be read, it is ignored and the call still rings |
Send iOS pushes as VoIP pushes through APNs (apns-push-type: voip, topic
<bundle id>.voip, priority 10, expiration 0) and Android pushes as
high-priority, data-only FCM messages with a 30-second lifetime. The request
details, the error codes, a reference implementation in Node.js and how to send a
test push are in Your calling server.
3. Record what happened. Your app reports each outcome with the call_id
(reportOutcome above). Mark the call missed if no answered arrives within
about 45 seconds of the push — a missed report alone decides nothing, since
another device or a repeated push can send one for a call that was answered; show missed calls in your app with a Call
back that is startCall with the same reason; and ring again only by your own
rules — once, at a sensible hour, is plenty. The conversation itself
(transcript, outcome, the actions the agent took) is in Call History under the
customer's name from identify().
Call states and outcomes
| State | Means |
|---|---|
idle | No call |
ringing | An agent's call is ringing on this device (incoming only) |
connecting | Answered, or placed by the customer; the line is opening |
active | The agent and the customer can hear each other |
ended | Over; reported once, then back to idle |
| Outcome | When | What your server does |
|---|---|---|
answered | The customer picked up an incoming call | Mark it answered; an ended or failed follows |
declined | They declined it | Don't ring again soon |
missed | Nobody answered within the ring timeout (30 s), another call was live, or (iOS) Do Not Disturb, a Focus or the block list stopped the ring | Show a missed call; retry by your rules |
ended | A call that connected is over — local (the customer hung up), remote (the agent did) or failed (the line broke or, on Android, the microphone stopped working; calls do not reconnect) | Close the record |
failed | It never connected — the microphone was refused, the line did not open within 15 seconds, or the agent could not be reached | Show "Couldn't connect"; offer to call back |
One call at a time: a ring that arrives during a call is reported missed
without disturbing the call, and startCall returns nothing while a call is
live.
Test it
- Place a call from the app on a real device. The agent should open with the reason you sent; in Call History the conversation appears under the customer's name.
- Ring the device from your server, three ways: with the app open, in the background, and closed (swiped away). Each must ring — the system's incoming call on iOS, the full-screen ring on a locked Android phone.
- Answer from the lock screen, lock it again and keep talking; switch to another app mid-call.
- Decline one and let one ring out:
declinedandmissedreach your server with the rightcall_id, and nothing rings twice. - Ring during a live call: it is reported
missed; the call is untouched. - Hang up from both sides; the system's call screen (iOS) and the ongoing notification (Android) go away with the call.
- A Bluetooth headset: the call follows it, as a phone call does.
Turn on debug in GraineConfig while testing on iOS and Android: the call's
main steps go to the Xcode console or logcat (tag Graine) — in React Native and
Flutter, set debug: true in the calls options — and audio, messages
and variable values are never logged. The full test plan, the App Store
and Google Play requirements and a go-live checklist are in
Test and release.
Troubleshooting
| What you see | Why, and what to do |
|---|---|
| Anything below | First read callSetupProblems (or the log at configure): it names what is missing, with the fix |
| iOS: a VoIP push does not ring | It needs apns-push-type: voip and apns-topic: <bundle id>.voip, sent to the token from enableVoIPPushes (not an FCM token), on the right APNs host — development builds use the sandbox |
| iOS: VoIP pushes stopped arriving altogether | iOS stops delivering to an app that does not report each push to CallKit. Call enableVoIPPushes at every launch, and send VoIP pushes only for calls |
| iOS: a call answered on the lock screen is silent | Background Modes must include Audio and Voice over IP |
| Android: no ring when the app is closed | Send a data-only message (no notification block) at high priority. An app with its own messaging service calls GraineCalls.handlePush(this, message.data) first in its onMessageReceived |
| Android: a heads-up notification instead of the full screen | Android 14+ without USE_FULL_SCREEN_INTENT — see above; it still rings. Offer calls.openCallSettings(GraineCallPermission.FULL_SCREEN) |
| Android: the call stops soon after the screen locks | Some phones' battery savers stop background work: ask the customer to allow the app in battery settings |
| The call connects but the agent does not know why it called | Send reason (and variables) in the push, and use {call_reason} in the prompt |
Every call failed at once | The key is wrong, or app:// is missing from the agent's Allowed domains |
Calls never use WebRTC: transport is websocket, with no reason | The WebRTC module is not in this build (React Native and Flutter: the build switch is off), or callTransport is websocket |
transportFallbackReason is dashboard (Android), or every install is on the WebSocket | The agent's In-app call audio is Standard. It applies from each app's next start |
ice_timeout on some networks | Those networks block WebRTC. The calls still connect, on the WebSocket. If most of your customers' networks do, choose Standard |
Some test calls fall back with mint_failed | A burst of calls from one network — more than about 10 a minute — is limited; the calls still connect, on the WebSocket |
React Native and Flutter
React Native and Flutter apps get the same native call engine as the iOS and Android SDKs: the same ring, call screens, CallKit and VoIP pushes, the same push from your server and the same outcomes. Your Dart or JavaScript configures it, starts calls and answers the agent's actions.
Both are native code, so adding them needs a new store build, not an
over-the-air update. On iOS, add the capabilities and Info.plist keys from
iOS, step 2 — with Expo, the calls package's config plugin adds them:
"plugins": ["@graineai/inapp-react-native-calls"] in app.json. On Android
there is nothing to add to your manifest. Both ask for the permissions with
requestCallPermissions() and say what is missing with callSetupProblems()
(above).
WebRTC is a build switch in both: ENV["GRAINE_WEBRTC"] = "1" at the top of
ios/Podfile (then pod install) and graineWebRtc=true in
android/gradle.properties — or GRAINE_WEBRTC=1 in the build's environment on
a build server. Then calls in both directions carry their audio over WebRTC
(callTransport in the options chooses; transport in the call's state says
which ran). Calls over WebRTC
A call screen of your own should treat ended and idle alike: on Android the
state can go straight to idle from ringing, connecting or active, and the
outcome says how the call ended. A call ends a conversation in the assistant
bar, and the bar waits until it is over — in React Native once you attach the
in-app SDK's client (0.40.2 or later), below.
Who draws the call while your app is open is callScreen. "sdk" (the
default) shows the SDK's call screen: the full-screen call activity on Android,
and a full-screen view over your app on iOS. "app" leaves it to your own
screen, drawn from the call's state. A locked iPhone always shows the system's
call screen.
React Native
Android pushes reach the engine by itself. Add Firebase to the Android app.
With Expo, name your google-services.json in the app config
(android.googleServicesFile) and add the package's plugin — it brings Firebase
Cloud Messaging. In a bare app, add google-services.json and the Google services
Gradle plugin, and graineFirebaseMessaging=true to android/gradle.properties.
The package's own messaging service then rings each call push, even when the app
was closed, and the engine reads the push token at launch — no JavaScript.
Already use @react-native-firebase/messaging? Its messaging service
receives every message instead, so hand the engine its pushes first — in
index.js, and while the app is open — and refreshed tokens:
Configure it once, at launch. The options are kept natively, so a VoIP push that wakes a closed iPhone rings before any JavaScript has run.
Give the call your screen context and actions. With the in-app SDK
(@graineai/inapp-react-native 0.40.2+), attach its client once inside
GraineProvider: who the customer is, your prompt variables, the current screen
and your useGraineAction handlers reach the call and stay in step. A call also
ends a conversation in the bar, and the bar waits until the call is over.
The customer calls the agent from your call button:
To offer a call from the assistant bar, ride a button on it:
accessory={<GraineBarAction label="Call Asha" onPress={…} />} on
GraineVoiceBar draws one in the brand's design
(GraineBarAction).
With callScreen: "app", draw the call from useGraineCall() — state,
callId, direction, reason, callerName, muted, speakerOn,
agentSpeaking, caption, connectedAt and error — with answer(), decline(), end(),
setMuted() and setSpeaker().
Flutter
Android pushes reach the SDK by itself. Add Firebase to the Android app:
google-services.json in android/app, the Google services Gradle plugin, and
Firebase Cloud Messaging in android/app/build.gradle.kts:
The SDK's own messaging service then rings each call push natively, even when the app was closed, and the SDK reads the push token at launch — no Dart.
Already use firebase_messaging? Its messaging service receives every
message instead, so hand the SDK its pushes first, and refreshed tokens:
attach() hands the call what your app already tells the agent through
GraineInApp — identify, setVariables, setScreen and registerAction —
and keeps it in step. The customer calls the agent with
GraineCalls.instance.startCall(reason: 'card_blocked'), which returns the
call's id, or null while another call is live.
With callScreen: GraineCallScreen.app, draw the call yourself:
GraineCalls.instance is a ValueListenable<GraineCallSnapshot>, so a
ValueListenableBuilder redraws your screen as the call moves, with answer(),
decline(), end(), setMuted() and setSpeaker().
Talking with the screen off
On the native call engine, the call keeps going with the screen off by itself.
An app that cannot add a native module (an Expo Go build, a release shipped
only over the air) can still hold a call on the in-app engine, with two
differences: the ring and the call screen are yours (a notification; not
CallKit, because a web view cannot keep its microphone during a CallKit call),
and the call must be told to outlive the screen — endOnBackground={false} on
GraineProvider (React Native, honoured for calls from 0.40.1) or
GraineConfig(endOnBackground: false) (Flutter), with a microphone foreground
service on Android. On iOS, whether a web view keeps its microphone in the
background has changed between iOS versions: test it on the versions you
support, and keep endOnBackground on where it mutes, so the call ends cleanly.

