Graine AI

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:

  1. Keep each customer's devices — the push token each install of your app sends you.
  2. Ring them — one push per device, through APNs or FCM.
  3. 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:

FieldTypeNotes
customer_idyour idWhose device it is
platformios or androidPicks APNs or FCM
tokenstringiOS: the VoIP token from enableVoIPPushes, as hex. Android: the FCM registration token
apns_environmentsandbox or productioniOS only. A build run from Xcode has a sandbox token, which the production host refuses
app_versionstringTells you which SDK version a device runs
last_seen_attimestampUpdated each time the app sends its token

Calls — one row per call:

FieldTypeNotes
call_idstringYours, and hard to guess (a UUID). Every outcome carries it back
customer_idyour id
directionagent or customerWho placed the call
reasonstringWhat you sent as reason, or passed to startCall
statusringing, answered, ended, declined, missed or failedSee Keeping the record right
end_reasonlocal, remote or failedWith ended: the customer hung up, the agent did, or the line broke
created_at, answered_at, ended_attimestampsTime 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:

RequestBodyWhen
PUT /devicesplatform, token, app_versionAt every launch, and whenever the token changes
POST /calls/{id}/outcomesoutcome, end_reasonFrom 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.

// Express. `requireSession` is your own authentication.
app.post("/calls/:id/outcomes", requireSession, async (req, res) => {
  const call = await db.calls.find(req.params.id)
    ?? await db.calls.insert({ id: req.params.id, customerId: req.user.id, direction: "customer", status: "answered" });
  if (call.customerId !== req.user.id) return res.sendStatus(404);
  await applyOutcome(call, req.body.outcome, req.body.end_reason);
  res.sendStatus(204);
});

Ringing a customer

  1. Check your calling policy — consent, the hour where the customer is, and how recently you called them. See When to call.
  2. Create the call record with a new call_id and status ringing.
  3. Push to the customer's device — see Which devices to ring.
  4. Start the ring timer: 45 seconds, after which a call still ringing is missed.

The push

Both platforms carry the same keys, and every value is a string:

Key
typerequiredagent_call, exactly
call_idrequiredYour id for this call — every outcome carries it back
reasonoptionalBecomes the prompt's call_reason
caller_nameoptionalWhat the ring shows, instead of the configured displayName
variablesoptionalA 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

RequestPOST /3/device/<VoIP token> over HTTP/2
Hostapi.push.apple.com for App Store and TestFlight builds; api.sandbox.push.apple.com for builds run from Xcode
authorizationbearer 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-typevoip
apns-topicYour bundle id followed by .voip
apns-priority10
apns-expiration0 — deliver now or not at all; a late ring is worse than none
BodyThe push's keys as one JSON object
StatusReasonWhat to do
200Accepted by APNs
400BadDeviceTokenA wrong token, or the wrong host for it — a sandbox token sent to production, or the reverse
403InvalidProviderToken, ExpiredProviderTokenYour JWT: the key, Key ID or Team ID is wrong, or it is more than an hour old
410UnregisteredThe token is dead: delete the device
413PayloadTooLargeOver 5 KB
429TooManyRequests, TooManyProviderTokenUpdatesSlow down — or you are making new JWTs too often
500, 503Retry 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

POST https://fcm.googleapis.com/v1/projects/<project id>/messages:send
Authorization: Bearer <OAuth token for a service account that may send messages>
Content-Type: application/json
 
{
  "message": {
    "token": "<FCM registration token>",
    "data": { "type": "agent_call", "call_id": "9b2e5c1e-…", "reason": "payment_due" },
    "android": { "priority": "HIGH", "ttl": "30s" }
  }
}
  • Data only. A message with a notification block 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.
StatusErrorWhat to do
200Accepted by FCM
400INVALID_ARGUMENTThe token or the message is malformed — a value that is not a string, for one
403SENDER_ID_MISMATCHThe token belongs to a different Firebase project
404UNREGISTEREDThe token is dead: delete the device
429QUOTA_EXCEEDEDSlow down
500, 503INTERNAL, UNAVAILABLERetry 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.

// ring.js
import http2 from "node:http2";
import crypto from "node:crypto";
import admin from "firebase-admin";
 
admin.initializeApp();   // reads GOOGLE_APPLICATION_CREDENTIALS: your Firebase service account
 
const APNS = {
  host: process.env.APNS_HOST ?? "https://api.push.apple.com",   // builds run from Xcode: https://api.sandbox.push.apple.com
  teamId: process.env.APNS_TEAM_ID,
  keyId: process.env.APNS_KEY_ID,
  key: process.env.APNS_P8,          // the .p8 file's contents
  topic: "com.acme.app.voip",        // your bundle id + ".voip"
};
 
// One signed token, reused for up to an hour: APNs refuses new ones more often than every 20 minutes.
let jwt = null, jwtAt = 0;
function apnsJwt() {
  if (jwt && Date.now() - jwtAt < 50 * 60_000) return jwt;
  const part = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
  const unsigned = `${part({ alg: "ES256", kid: APNS.keyId })}.${part({ iss: APNS.teamId, iat: Math.floor(Date.now() / 1000) })}`;
  const signature = crypto.sign("sha256", Buffer.from(unsigned), { key: APNS.key, dsaEncoding: "ieee-p1363" });
  jwt = `${unsigned}.${signature.toString("base64url")}`;
  jwtAt = Date.now();
  return jwt;
}
 
// One HTTP/2 connection, kept open and re-made only when it closes.
let session = null;
function apns() {
  if (!session || session.closed || session.destroyed) {
    session = http2.connect(APNS.host);
    session.on("error", () => { session = null; });
    session.on("goaway", () => { session = null; });
  }
  return session;
}
 
export function pushData(call) {
  const data = { type: "agent_call", call_id: call.id };
  if (call.reason) data.reason = call.reason;
  if (call.callerName) data.caller_name = call.callerName;
  if (call.variables) data.variables = JSON.stringify(call.variables);
  return data;
}
 
// Resolves "sent", or "gone" when the device should be forgotten; rejects on anything else.
export function ringIos(voipToken, call) {
  return new Promise((resolve, reject) => {
    const req = apns().request({
      ":method": "POST",
      ":path": `/3/device/${voipToken}`,
      authorization: `bearer ${apnsJwt()}`,
      "apns-push-type": "voip",
      "apns-topic": APNS.topic,
      "apns-priority": "10",
      "apns-expiration": "0",
    });
    let status = 0, body = "";
    req.setEncoding("utf8");
    req.on("response", (headers) => { status = headers[":status"]; });
    req.on("data", (chunk) => { body += chunk; });
    req.on("end", () => {
      if (status === 200) resolve("sent");
      else if (status === 410) resolve("gone");
      else reject(new Error(`APNs ${status} ${body}`));
    });
    req.on("error", reject);
    req.end(JSON.stringify(pushData(call)));
  });
}
 
export async function ringAndroid(fcmToken, call) {
  try {
    await admin.messaging().send({
      token: fcmToken,
      data: pushData(call),                        // data only: the SDK rings even when the app is closed
      android: { priority: "high", ttl: 30_000 },  // milliseconds: a ring is now or never
    });
    return "sent";
  } catch (e) {
    if (e.code === "messaging/registration-token-not-registered" || e.code === "messaging/invalid-registration-token") return "gone";
    throw e;
  }
}
 
export async function ringCustomer(customerId, call) {
  const [device] = await db.devices.mostRecentlySeen(customerId, 1);
  if (!device) return null;
  await db.calls.insert({ ...call, customerId, direction: "agent", status: "ringing", createdAt: new Date() });
  const send = device.platform === "ios" ? ringIos : ringAndroid;
  const result = await send(device.token, call).catch((e) => { console.error(e); return "error"; });
  if (result === "gone") await db.devices.remove(device.token);
  setTimeout(() => closeIfStillRinging(call.id), 45_000);   // in production, a durable job
  return result;
}

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

ringingansweredendeddeclinedmissedfailedansweredendedlocal · remote · failedyour timer: no end reported by your longest calldeclinedmissed, or no answerwithin ~45 sfailed: never connectedA thick border is final: later outcomesfor that call change nothing.

Your record of an agent's call. Thick borders are final: anything that arrives for a closed call changes nothing.

  • answered and declined decide a ringing call. A missed from 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.
  • ended and failed close an answered call. failed means the line never opened; ended comes 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 ringing at 45 seconds becomes missed, and one still answered after your longest call becomes ended with 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.
const FINAL = new Set(["ended", "declined", "missed", "failed"]);
 
export async function applyOutcome(call, outcome, endReason) {
  if (FINAL.has(call.status)) return;   // closed: ignore late, repeated and second-device reports
  const now = new Date();
  if (outcome === "answered" && call.status === "ringing") return db.calls.update(call.id, { status: "answered", answeredAt: now });
  if (outcome === "declined" && call.status === "ringing") return db.calls.update(call.id, { status: "declined", endedAt: now });
  if (outcome === "ended") return db.calls.update(call.id, { status: "ended", endReason, endedAt: now });
  if (outcome === "failed") return db.calls.update(call.id, { status: "failed", endedAt: now });
  // "missed" from one device decides nothing: the ring timer does.
}
 
export async function closeIfStillRinging(callId) {
  await db.calls.updateWhere({ id: callId, status: "ringing" }, { status: "missed", endedAt: new Date() });
}

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 startCall with 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

MeasureFromWatch for
Push errors, by statusYour push sender403 from APNs (your key or JWT); a jump in 410 or UNREGISTERED (a release that stopped sending tokens)
Answer rateanswered ÷ calls rungA drop after a release or at certain hours
Declines and missesdeclined, missedCalls at the wrong time, or rung too often
Failuresfailed, ended with failedA spike: a wrong key, app:// missing from Allowed domains, or networks
Time to answer and call lengthanswered_at, ended_atHow 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:

curl --http2 \
  -H "authorization: bearer $JWT" \
  -H "apns-push-type: voip" \
  -H "apns-topic: com.acme.app.voip" \
  -H "apns-priority: 10" \
  -H "apns-expiration: 0" \
  -d '{"type":"agent_call","call_id":"test-1","reason":"support"}' \
  "https://api.sandbox.push.apple.com/3/device/$VOIP_TOKEN"

Android. Send the request from the FCM section with an OAuth token for your Firebase service account:

curl -X POST "https://fcm.googleapis.com/v1/projects/$PROJECT_ID/messages:send" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"message":{"token":"'"$FCM_TOKEN"'","data":{"type":"agent_call","call_id":"test-1","reason":"support"},"android":{"priority":"HIGH","ttl":"30s"}}}'

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.

On this page