Skip to content

API and push payloads ​

A complete reference design you can implement in any language. The endpoint names are suggestions; the push payloads are exactly what Callx decodes. Why the design looks like this is on the backend overview; when each endpoint is called is in call flows.

What your backend does ​

ResponsibilityNotes
Authenticate users and devicesDerive identity from the session, never from JSON fields
Store push tokens per installationiOS VoIP tokens and Android FCM tokens
Create callsUnique callId, caller, callee, expiry
Send invitationsAPNs VoIP push or FCM data message with a callx payload
Arbitrate answersThe first answering device wins; others get answeredElsewhere
Deliver call eventsAccepted and ended, over your signaling connection
Issue media credentialsShort-lived, per participant, after authorization

Data model ​

Use a UUID as callId and never reuse it.

FieldMeaning
callIdGlobally unique call identity
eventIdUnique identity of each network event; devices deduplicate by it
revisionPer-call counter as a decimal string, incremented in the same transaction as each change
operationIdIdempotency key for one requested action
installationIdOne app installation; not a credential

Store per call: participants, state (ringing, accepted, ended), the winning installation, created, expiry and ended timestamps, the end reason and the last revision. Keep ended calls long enough that a delayed invitation cannot resurrect them.

Endpoints ​

All over authenticated HTTPS. Authorize call membership on every request.

Method and routeRequestResponse
PUT /v1/installations/{id}/push-tokens/{kind}Token, app ID, APNs environmentSaved registration
DELETE /v1/installations/{id}/push-tokens/{kind}No content
POST /v1/callscallId, calleeUserId, operationIdCall snapshot
POST /v1/calls/{id}/answeroperationId, installationId, expectedRevisionWinner snapshot, or conflict
POST /v1/calls/{id}/endoperationId, reasonTerminal snapshot
GET /v1/calls/{id}Current snapshot, including ended calls
POST /v1/calls/{id}/media-sessionShort-lived media credentials
GET /v1/call-events?cursor=…Events and next cursor, or an explicit gap

kind is apnsVoip or fcm, matching the type that getPushToken() returns (voip or fcm).

A call snapshot:

json
{
  "schemaVersion": 1,
  "callId": "85a4fd88-b5c3-4f79-a2cf-a7db9df06750",
  "revision": "1",
  "state": "ringing",
  "caller": {"userId": "user-a", "displayName": "Alex", "handle": "callx:user-a"},
  "calleeUserId": "user-b",
  "createdAtMs": 1790000000000,
  "expiresAtMs": 1790000030000
}

Idempotency and arbitration ​

  • Make create, answer and end idempotent on (account, operationId, request fingerprint). A retry returns the original outcome; the same ID with a different payload returns a conflict.
  • Select the answer winner atomically while the call is ringing and unexpired. Later answers receive the winner's snapshot and no media session.
  • Write each call change and its delivery job in one transaction (a transactional outbox); a worker sends the pushes and events with retries.

Push provider acceptance means accepted for delivery, not delivered and not rung.

iOS: APNs VoIP invitation ​

Send through the APNs HTTP/2 API to the device's VoIP token:

http
POST /3/device/VOIP_DEVICE_TOKEN HTTP/2
host: api.push.apple.com
authorization: bearer APNS_PROVIDER_JWT
apns-push-type: voip
apns-topic: com.example.calls.voip
apns-priority: 10
apns-expiration: 0
content-type: application/json

{
  "aps": {},
  "callx": {
    "schemaVersion": 1,
    "eventId": "evt-invite-001",
    "type": "call.invited",
    "callId": "85a4fd88-b5c3-4f79-a2cf-a7db9df06750",
    "revision": "1",
    "displayName": "Alex",
    "handle": "callx:user-a",
    "issuedAtMs": 1790000000000,
    "expiresAtMs": 1790000030000
  }
}
  • The topic is your bundle ID plus .voip. Use api.sandbox.push.apple.com for development builds.
  • apns-expiration: 0 asks APNs not to store the push for later; a call that cannot ring now should not ring in ten minutes.
  • Send only invitations as VoIP pushes. Every VoIP push must produce a CallKit report; Callx makes one even for invitations that must not ring, but iOS penalizes apps that use VoIP pushes for anything else.

Android: FCM invitation ​

Send through the FCM HTTP v1 API. FCM data values are strings, so the invitation is JSON in one string:

http
POST /v1/projects/FIREBASE_PROJECT_ID/messages:send HTTP/1.1
host: fcm.googleapis.com
authorization: Bearer GOOGLE_OAUTH_ACCESS_TOKEN
content-type: application/json

{
  "message": {
    "token": "ANDROID_FCM_TOKEN",
    "android": {"priority": "HIGH", "ttl": "30s"},
    "data": {
      "callx": "{\"schemaVersion\":1,\"eventId\":\"evt-invite-001\",\"type\":\"call.invited\",\"callId\":\"85a4fd88-b5c3-4f79-a2cf-a7db9df06750\",\"revision\":\"1\",\"displayName\":\"Alex\",\"handle\":\"callx:user-a\",\"issuedAtMs\":1790000000000,\"expiresAtMs\":1790000030000}"
    }
  }
}
  • Use high priority and no notification block, so the app's code presents the call.
  • Set ttl to the time left until expiresAtMs, in whole seconds. FCM then holds the invitation while the device's connection is down (after a reboot, in Doze, or during a network switch) and still delivers it in time; Callx ignores an invitation that arrives after it expires. Do not use "0s": FCM drops a zero-TTL message whenever the device is not connected at that moment, and the call never rings.
  • Do not send invitations you already know are expired, cancelled or busy. FCM can lower the priority of apps whose high-priority messages do not lead to a visible notification.
  • Remove tokens FCM reports as invalid.

The invitation fields ​

FieldRequiredMeaning
schemaVersionYes1
typeYescall.invited
callIdYesThe call's ID (UUID recommended); same format as any Callx ID
displayNameYesShown on the incoming-call UI; 1–256 UTF-8 bytes
handleYesYour app's address for the caller, for example callx:user-a; 1–256 bytes
eventIdNoUnique per message; recommended so hosts can deduplicate
revisionNoThe call's revision when sent, as a decimal string
issuedAtMsNoWhen the backend sent it
expiresAtMsNoAfter this time the call does not ring; also caps the ring deadline
videoNotrue rings as a video call: CallKit hasVideo and Core-Telecom's video call type. See video calls

Android: push signals (3.0.1) ​

On Android, call.ended and call.accepted may also travel as FCM data messages under the callx key, with normal priority and the same JSON as the signaling events below. Callx maps them to remoteEnded / remoteAnswered without app code, so a killed app stops ringing when the caller cancels. call.ended carries reason (default remoteEnded). Older cores ignore these messages without ringing. See call flows.

Signaling events ​

Send call.accepted and call.ended over your authenticated signaling connection (a WebSocket or your provider's channel), not as pushes:

json
{
  "schemaVersion": 1,
  "eventId": "evt-answer-002",
  "type": "call.accepted",
  "callId": "85a4fd88-b5c3-4f79-a2cf-a7db9df06750",
  "revision": "2",
  "occurredAtMs": 1790000005000,
  "answeredByInstallationId": "installation-b1"
}

The native host deduplicates by eventId and per-call revision, then maps each event (where these enter Callx):

ObservationHost calls (native), or from app code (3.0.1)
Invitation over signaling while the app runsingress.handleInvitation(invitation)
The callee accepted your outgoing callingress.remoteAnswered(callId); Dart CallxSignaling.remoteAnswered, TS reportRemoteAnswered
Another device of this user answeredingress.remoteEnded(callId, "answeredElsewhere"); Dart CallxSignaling.remoteEnded, TS reportRemoteEnded
The caller cancelled before an answeringress.remoteEnded(callId, "callerCancelled")
The other party hung upingress.remoteEnded(callId, "remoteEnded")
Media actually flowsruntime.mediaConnected(callId) (adapters do this)

Do not inject a second local answer for an answer this device made: the command already committed it.

Security checklist ​

  • APNs keys and Firebase service accounts live on your server only.
  • Media credentials are short-lived and fetched after authorization, never put in pushes (media credentials).
  • Unregister or rebind push tokens on sign-out, so a signed-out user stops receiving calls.
  • Do not log push tokens or full payloads.

Test it end to end ​

  1. Two signed builds, users A and B, tokens registered.
  2. A creates a call; B receives the invitation and rings with the app killed.
  3. B answers; A receives call.accepted; both sides reach connecting.
  4. Media flows; both reach active.
  5. A hangs up; B ends with remoteEnded.
  6. Repeat with: cancel before answer, a duplicate invitation, two devices answering, network loss, the lock screen, an engine restart and expired credentials.

The testkit can play user A for you.

Released under the MIT License. No telemetry, in the library or on this site.