Your calling server
The server side of in-app calls — what you store, the requests your app makes, ringing through APNs and FCM, keeping each call's record right, and when to call.
Your agent holds the conversation; your server decides who it calls and when. It needs no Graine API to do so. The ring is a push you send through Apple's and Google's push services, and every outcome comes back through your own app. In this mode Graine never holds your customers' push tokens or rings a device itself.
This page is that mode. The other — hosted ringing — needs no server: you upload your push credentials once and Graine rings for you.
Your server has three jobs:
- Keep each customer's devices — the push token each install of your app sends you.
- Ring them — one push per device, through APNs or FCM.
- Keep each call's record — from the outcomes your app reports, with timers for the reports that never come.
What you store
Devices — one row per install of your app:
| Field | Type | Notes |
|---|---|---|
customer_id | your id | Whose device it is |
platform | ios or android | Picks APNs or FCM |
token | string | iOS: the VoIP token from enableVoIPPushes, as hex. Android: the FCM registration token |
apns_environment | sandbox or production | iOS only. A build run from Xcode has a sandbox token, which the production host refuses |
app_version | string | Tells you which SDK version a device runs |
last_seen_at | timestamp | Updated each time the app sends its token |
Calls — one row per call:
| Field | Type | Notes |
|---|---|---|
call_id | string | Yours, and hard to guess (a UUID). Every outcome carries it back |
customer_id | your id | |
direction | agent or customer | Who placed the call |
reason | string | What you sent as reason, or passed to startCall |
status | ringing, answered, ended, declined, missed or failed | See Keeping the record right |
end_reason | local, remote or failed | With ended: the customer hung up, the agent did, or the line broke |
created_at, answered_at, ended_at | timestamps | Time to answer and call length come from these |
Tokens change. iOS can issue a new VoIP token at any launch, and FCM calls
onNewToken when it rotates one. Save the token every time the app sends it, and
treat it as belonging to the install, not the customer: move it to whoever signs
in, and delete it when they sign out. An iOS VoIP token is not the same as your
app's alert-notification token or an FCM token — keep it apart. Delete a token
when iOS hands your app nil for it, when APNs answers 410, or when FCM
answers UNREGISTERED.
The requests your app makes
Your app reaches your server with your own sign-in, never Graine's key. Two requests are enough; the paths are examples:
| Request | Body | When |
|---|---|---|
PUT /devices | platform, token, app_version | At every launch, and whenever the token changes |
POST /calls/{id}/outcomes | outcome, end_reason | From the SDK's outcome listener, for every outcome |
Accept an outcome only for a call that belongs to the signed-in customer, and make
the request safe to repeat: a phone retries when its network blinks, and if you
ring two devices, both report. For a call the customer places, startCall
returns the call's id — create the record (direction: customer) when the first
outcome for it arrives.
Ringing a customer
- Check your calling policy — consent, the hour where the customer is, and how recently you called them. See When to call.
- Create the call record with a new
call_idand statusringing. - Push to the customer's device — see Which devices to ring.
- Start the ring timer: 45 seconds, after which a call still
ringingismissed.
The push
Both platforms carry the same keys, and every value is a string:
| 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, instead of 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 |
Keep pushes small and plain. APNs accepts at most 5 KB for a VoIP push and FCM 4 KB
of data. A push travels through Apple or Google and waits there until the phone
is reachable, so it should carry no secrets and as little personal data as you
can manage: send an identifier as the reason (invoice_overdue,
order_81723) and let the agent look up the details through
your actions during the call. The SDK masks personal data
in variables before it reaches the agent, but sends the reason as it is. A
call carries up to 12 variables in all — see the
limits.
iOS: a VoIP push through APNs
| Request | POST /3/device/<VoIP token> over HTTP/2 |
| Host | api.push.apple.com for App Store and TestFlight builds; api.sandbox.push.apple.com for builds run from Xcode |
authorization | bearer and a JWT signed with your APNs auth key: ES256, kid your Key ID, iss your Team ID, iat the time. Reuse it; make a new one every 20 to 60 minutes |
apns-push-type | voip |
apns-topic | Your bundle id followed by .voip |
apns-priority | 10 |
apns-expiration | 0 — deliver now or not at all; a late ring is worse than none |
| Body | The push's keys as one JSON object |
| Status | Reason | What to do |
|---|---|---|
200 | Accepted by APNs | |
400 | BadDeviceToken | A wrong token, or the wrong host for it — a sandbox token sent to production, or the reverse |
403 | InvalidProviderToken, ExpiredProviderToken | Your JWT: the key, Key ID or Team ID is wrong, or it is more than an hour old |
410 | Unregistered | The token is dead: delete the device |
413 | PayloadTooLarge | Over 5 KB |
429 | TooManyRequests, TooManyProviderTokenUpdates | Slow down — or you are making new JWTs too often |
500, 503 | Retry with backoff, while ringing still makes sense |
Keep one HTTP/2 connection to APNs open and send every push over it. Apple asks providers not to connect and disconnect for each push, and treats rapid reconnecting as abuse.
Android: a data message through FCM
- Data only. A message with a
notificationblock is drawn by the system while your app is in the background and never reaches the SDK, so it cannot ring. - High priority. A normal-priority message can wait for the phone to leave its battery-saving sleep; a ring cannot. FCM may lower the priority of apps whose high-priority messages never lead to anything visible — every call push makes the SDK ring, so send high priority for calls only.
- A 30-second lifetime. A ring delivered later is not useful.
| Status | Error | What to do |
|---|---|---|
200 | Accepted by FCM | |
400 | INVALID_ARGUMENT | The token or the message is malformed — a value that is not a string, for one |
403 | SENDER_ID_MISMATCH | The token belongs to a different Firebase project |
404 | UNREGISTERED | The token is dead: delete the device |
429 | QUOTA_EXCEEDED | Slow down |
500, 503 | INTERNAL, UNAVAILABLE | Retry with backoff, while ringing still makes sense |
An app the customer has force-stopped in Android's settings receives no messages until it is opened again. That is Android's rule, and no message setting changes it.
A reference implementation
Node.js 18 or later: APNs over a kept-open HTTP/2 connection with no
dependencies, and FCM through firebase-admin.
db is your own storage, and the timer should be a durable job in production — a
server restart must not leave calls ringing forever.
Which devices to ring
Ring the device the customer used most recently. Every device you push to rings on
its own until it is answered, declined or times out there: answering on one phone
does not stop the ring on a tablet. Ringing several devices therefore means the
others keep ringing for up to the ring timeout after one is answered, and then
report missed. If you do ring several, your record must ignore those (below).
Keeping the record right
Your record of an agent's call. Thick borders are final: anything that arrives for a closed call changes nothing.
answeredanddeclineddecide a ringing call. Amissedfrom one device does not: another device may still be ringing, or have been answered. Your ring timer closes a call nobody answered: 45 seconds after the push covers the SDK's 30-second ring plus delivery.endedandfailedclose an answered call.failedmeans the line never opened;endedcomes with how it ended.- Reports can go missing. Your app reports outcomes over the network, after the
fact. A phone that loses signal, or an app deleted mid-call, means a report may
never arrive. So a call still
ringingat 45 seconds becomesmissed, and one stillansweredafter your longest call becomesendedwith no reason. The conversation itself is always in Call History. - A closed record ignores what follows. Late, repeated and second-device outcomes all arrive; none of them reopens a call.
When to call
- Consent. Ring only customers who asked for a call or agreed to calls from your app, and give them a way to stop.
- Hours. Use the customer's time zone, and stay out of the night.
- How often. One ring, and at most one more after a missed call, well spaced. After a decline, wait until the customer asks.
- Call back. Show missed calls in your app with a Call back button: it is
startCallwith the same reason, and the customer is the one calling. - The phone's own settings still apply. Do Not Disturb and Focus treat these calls as they treat any call.
Security and privacy
- Pushes carry no secrets and as little personal data as possible — an identifier as the reason, the details fetched during the call.
- Call ids are hard to guess, and every outcome request is checked against the signed-in customer.
- Device tokens are personal data: delete them on sign-out and when an account is deleted.
- Your endpoints use your own sign-in. The publishable key inside your app is
public by design: it opens only the agent it belongs to, from the origins in the
agent's Allowed domains, which for an app is
app://.
What to watch
| Measure | From | Watch for |
|---|---|---|
| Push errors, by status | Your push sender | 403 from APNs (your key or JWT); a jump in 410 or UNREGISTERED (a release that stopped sending tokens) |
| Answer rate | answered ÷ calls rung | A drop after a release or at certain hours |
| Declines and misses | declined, missed | Calls at the wrong time, or rung too often |
| Failures | failed, ended with failed | A spike: a wrong key, app:// missing from Allowed domains, or networks |
| Time to answer and call length | answered_at, ended_at | How customers take the calls |
Send a test push
iOS. Apple's Push Notifications Console
sends a push to one device without any server code: choose the environment,
the voip push type and your app's VoIP topic, paste the token from
enableVoIPPushes, and use the push's keys as the payload. With curl, given a
JWT from apnsJwt() above:
Android. Send the request from the FCM section with an OAuth token for your Firebase service account:
Use a fresh call_id for every test. Your app reports each outcome with it, so
you can follow a test call from the push to its end.

