Skip to main content
🤖 LLM Friendly: This page is available in raw Markdown format for LLM consumption:voip-push-incoming-call.md|Get full documentation:llms.txt/llms-full.txt

VoIP Push Incoming Calls

Firetell provides a low-latency VoIP Push Notification system that wakes mobile applications when an incoming call arrives for an agent. This ensures that agents never miss inbound calls, even when the mobile app is in the background, suspended, or terminated by the operating system.

When an inbound call rings an agent, Firetell dispatches an asynchronous VoIP push notification via Apple Push Notification service (APNs VoIP) for iOS and Firebase Cloud Messaging (FCM) for Android.

sequenceDiagram
autonumber
actor Caller
participant Firetell as Firetell Platform
participant Push as APNs / FCM Gateway
actor Agent as Mobile App (iOS / Android)

Caller->>Firetell: Inbound Call (Call Flow / Direct Ring)
Firetell->>Push: Dispatch High-Priority VoIP Push
Push->>Agent: Wakeup Push (call_token, ws_url, ring_timeout_secs)
Note over Agent: Display Native CallKit / Telecom UI<br/>Start ring_timeout_secs fallback timer
Agent->>Firetell: User Answers -> Open WebSocket (ws_url)
Agent->>Firetell: Send session.connect { token: call_token } (within 3s)
Firetell->>Agent: session.connected & call.offer (SDP)
Agent->>Firetell: call.answer (SDP)
Firetell->>Agent: WebRTC Audio Connected

Prerequisites: Registering Device Push Tokens​

Before a mobile device can receive incoming call pushes, it must register its VoIP push notification token with the Firetell Agent API.

1. Obtain Push Token on Device​

  • iOS (Swift / Objective-C): Use Apple's PushKit framework (PKPushRegistry) with the .voIP push type.
  • Android (Kotlin / Java): Use Firebase Cloud Messaging (FirebaseMessaging.getInstance().token).
important

iOS Token Separation

On iOS, the VoIP push token (from PKPushRegistry) is separate from the standard APNs token (from UNUserNotificationCenter). You must register the VoIP token using the dedicated VoIP registration endpoint.

2. Register VoIP Push Token​

Call the POST /me/devices/voip-push-token endpoint in the Agent API:

curl -X POST "https://{workspace_id}.firetell.app/api/v1/me/devices/voip-push-token" \
-H "Authorization: Bearer YOUR_AGENT_JWT" \
-H "Content-Type: application/json" \
-d '{
"push_token": "a1b2c3d4e5f6...voip_device_token",
"device_id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"platform": "ios",
"os_version": "17.5.1",
"app_version": "1.2.0",
"device_model": "iPhone 15 Pro"
}'

For full request parameters and response schemas, refer to the Register VoIP Push Notification Token documentation.

info

Non-VoIP / Standard Push Notifications

To receive call cancellation and termination alerts (call.canceled, call.ended) that dismiss the background ringing screen when the caller hangs up or another agent answers, register the device's regular push token via POST /me/devices/notification-push-token. On iOS, supply your standard APNs or FCM token; on Android, the same FCM token can be supplied to both endpoints.

tip

Token Lifecycle & Logout

  1. Re-registration: Always invoke POST /me/devices/voip-push-token on every application cold launch or whenever PushKit / FCM issues a refreshed token.
  2. Logout: When the agent signs out, invoke POST /me/logout. Firetell will invalidate the active session and remove the push token so the device no longer wakes up for inbound calls.

Push Payload Specification​

When an incoming call rings an agent, Firetell sends a push notification payload containing all metadata required to display the native incoming call UI and authenticate the call session.

iOS (APNs VoIP Payload)​

On iOS, APNs delivers the payload to PKPushRegistryDelegate. The root payload contains the event metadata alongside an empty aps: {} dictionary:

{
"aps": {},
"event": "call.ring",
"workspace_id": "ws_123456789",
"call_id": "cl_a1b2c3d4e5f6g7h8i9j0k1",
"call_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"ws_url": "wss://ws.firetell.app/ws",
"ring_timeout_secs": 30,
"caller_number": "+12025550143",
"caller_name": "Acme Corp Support",
"caller_avatar": null,
"callee_number": "1001",
"callee_name": "Agent John Doe",
"is_transfer": false,
"transfer_reason": null,
"is_video": false
}
  • APNs Topic: {BUNDLE_ID}.voip (e.g. com.firetell.agent.voip)
  • APNs Priority: 10 (immediate delivery)
  • APNs Expiry: Automatically set to now + ring_timeout_secs so expired calls are never delivered.

Android (FCM Data Message)​

On Android, Firetell sends a high-priority data-only message (without a notification key), giving your application complete control to trigger a full-screen incoming call UI or Android Telecom ConnectionService:

{
"event": "call.ring",
"workspace_id": "ws_123456789",
"call_id": "cl_a1b2c3d4e5f6g7h8i9j0k1",
"call_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"ws_url": "wss://ws.firetell.app/ws",
"ring_timeout_secs": "30",
"caller_number": "+12025550143",
"caller_name": "Acme Corp Support",
"caller_avatar": "",
"callee_number": "1001",
"callee_name": "Agent John Doe",
"is_transfer": "false",
"transfer_reason": "",
"is_video": "false"
}
  • Android Priority: high
  • FCM TTL: Set to ring_timeout_secs * 1000 ms.
  • Value types: In FCM data messages, all values are encoded as strings. Parse ring_timeout_secs as an integer and is_transfer, is_video as booleans.

Incoming Call Payload Reference (call.ring)​

FieldTypeDescription
eventstringAlways "call.ring". Identifies this push as an incoming call alert.
workspace_idstringThe Firetell workspace identifier handling the call.
call_idstringUnique identifier for the incoming call session.
call_tokenstringShort-lived JWT credential used to authenticate the signaling WebSocket.
ws_urlstringThe WebSocket endpoint of the signaling server assigned to this call.
ring_timeout_secsnumber | stringRing timeout in seconds determined by the Call Flow node (default 30). Use this to run a local countdown timer.
caller_numberstringPhone number or extension of the calling party.
caller_namestringDisplay name of the caller (e.g., contact name or VIP tag).
caller_avatarstring | nullAvatar URL of the calling party, or null / empty string if unavailable (e.g., external PSTN caller).
callee_numberstringDestination phone number, queue number, or agent extension.
callee_namestringDisplay name of the callee or target team.
is_transferboolean | stringtrue if this incoming call is being transferred by another agent or supervisor.
transfer_reasonstring | nullReason for transfer, or null / empty string if direct call.
is_videoboolean | stringtrue if the incoming call includes video media (video call invitation), false for audio-only.

Call Cancellation & End Payloads (call.canceled & call.ended)​

When an active or ringing call ends before being answered (for example: the caller hangs up, the Call Flow timeout forwards the call elsewhere, or another agent in the team answers), Firetell dispatches a high-priority normal/background push notification (call.canceled or call.ended) to all ringing devices. This allows the mobile app to immediately dismiss its native incoming call screen (CallKit or Android full-screen intent) without waiting for ring_timeout_secs to expire.

iOS (APNs Silent Background Notification)​

Delivered to application(_:didReceiveRemoteNotification:fetchCompletionHandler:) or your notification extension:

{
"aps": {
"content-available": 1
},
"event": "call.canceled",
"workspace_id": "ws_123456789",
"call_id": "cl_a1b2c3d4e5f6g7h8i9j0k1",
"reason": "caller_hangup",
"timestamp": "2026-09-11T07:30:00.000Z"
}
  • APNs Topic: {BUNDLE_ID} (main app bundle ID, without .voip)
  • APNs Push Type: background
  • APNs Priority: 5 (silent background wake)

Android & iOS via Firebase (FCM Data Message)​

Delivered to FirebaseMessagingService.onMessageReceived() (Android) or Firebase iOS delegate:

{
"event": "call.canceled",
"workspace_id": "ws_123456789",
"call_id": "cl_a1b2c3d4e5f6g7h8i9j0k1",
"reason": "caller_hangup",
"timestamp": "2026-09-11T07:30:00.000Z"
}
  • Android Priority: high
  • FCM TTL: 60000 (60 seconds)

Cancel & End Payload Field Reference​

FieldTypeDescription
eventstring"call.canceled" (canceled while ringing) or "call.ended" (call terminated).
workspace_idstringThe Firetell workspace identifier.
call_idstringUnique identifier of the call session to dismiss.
reasonstring | nullReason description (e.g. "caller_hangup", "timeout", "answered_elsewhere", "rejected").
timestampstringISO-8601 timestamp when the cancellation/end event occurred.

iOS Implementation (PushKit & CallKit)​

Apple requires all VoIP pushes on iOS 13+ to be reported immediately to CallKit. Failure to report the call to CXProvider inside pushRegistry(_:didReceiveIncomingPushWith:for:completion:) will cause the system to terminate your app and may revoke your app's VoIP push entitlement.

1. Initialize PushKit​

import PushKit
import CallKit

class VoIPPushManager: NSObject, PKPushRegistryDelegate {
static let shared = VoIPPushManager()
private var voipRegistry: PKPushRegistry?

func registerVoIPPush() {
let registry = PKPushRegistry(queue: DispatchQueue.main)
registry.delegate = self
registry.desiredPushTypes = [.voIP]
self.voipRegistry = registry
}

func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
if type == .voIP {
let token = pushCredentials.token.map { String(format: "%02.2hhx", $0) }.joined()
// Send token to Firetell Agent API: POST /me/devices/voip-push-token
DeviceService.registerVoipToken(token)
}
}
}

2. Handle Incoming VoIP Push with CallKit​

func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP,
let dict = payload.dictionaryPayload as? [String: Any],
let event = dict["event"] as? String, event == "call.ring",
let callIdString = dict["call_id"] as? String,
let callUUID = UUID(uuidString: callIdString) ?? UUID() else {
completion()
return
}

let callerName = dict["caller_name"] as? String ?? "Incoming Call"
let callerNumber = dict["caller_number"] as? String ?? "Unknown"
let ringTimeoutSecs = (dict["ring_timeout_secs"] as? Int) ?? 30
let callToken = dict["call_token"] as? String ?? ""
let wsUrl = dict["ws_url"] as? String ?? ""

// Save session info for when the agent answers
CallSessionStore.shared.save(callId: callIdString, token: callToken, wsUrl: wsUrl)

// Configure CallKit Update
let isVideo = (dict["is_video"] as? Bool) ?? false
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: callerNumber)
update.localizedCallerName = callerName
update.hasVideo = isVideo
update.supportsGrouping = false
update.supportsUngrouping = false
update.supportsHolding = true

// Report incoming call to CallKit
let provider = CallKitManager.shared.cxProvider
provider.reportNewIncomingCall(with: callUUID, update: update) { [weak self] error in
if error == nil {
// Start local countdown fallback timer
self?.startRingTimeoutTimer(uuid: callUUID, seconds: ringTimeoutSecs)
}
// MUST call completion handler
completion()
}
}

3. Handle Answer & Signaling Handshake​

When the user answers via the native CallKit screen, CXProviderDelegate receives provider(_:perform: CXAnswerCallAction). You must connect to ws_url and authenticate using call_token:

func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
guard let session = CallSessionStore.shared.activeSession else {
action.fail()
return
}

// Cancel ring timeout timer
self.cancelRingTimeoutTimer()

// 1. Open native WebSocket to ws_url
WebSocketManager.shared.connect(url: session.wsUrl) { [weak self] isConnected in
guard isConnected else {
action.fail()
return
}

// 2. Send session.connect event within 3 seconds
let handshake: [String: Any] = [
"event": "session.connect",
"data": ["token": session.callToken]
]
WebSocketManager.shared.send(handshake)

// 3. Complete CallKit action
action.fulfill()
}
}

4. Handle Decline / Reject via HTTP Endpoint​

When an agent declines an incoming call on the lock screen, CXProviderDelegate receives provider(_:perform: CXEndCallAction).

tip

Use HTTP POST Instead of WebSocket for Call Rejection

Opening a WebSocket connection and sending session.connect just to reject a call takes several seconds and risks being terminated by iOS when the app is suspended.

Firetell provides a fast, dedicated HTTP endpoint that completes in ~50ms:

POST https://{workspace_id}.firetell.app/api/v1/call-center/calls/{call_id}/reject
Authorization: Bearer <call_token>
Content-Type: application/json

{
"reason": "declined"
}

You can use the call_token directly from the VoIP push payload as the Bearer token — no need to retrieve or refresh your main account JWT.

func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
self.cancelRingTimeoutTimer()

guard let session = CallSessionStore.shared.activeSession else {
action.fulfill()
return
}

// Fast HTTP reject using call_token from push payload
let url = URL(string: "https://\(workspaceId).firetell.app/api/v1/call-center/calls/\(session.callId)/reject")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(session.callToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["reason": "declined"])

URLSession.shared.dataTask(with: request) { _, _, _ in
CallSessionStore.shared.clear()
action.fulfill()
}.resume()
}

5. Handle Remote Cancellation / End (call.canceled & call.ended)​

If the caller hangs up before answer, or another agent in the queue answers the call, Firetell dispatches a silent background notification to your registered notification_push_token (via standard APNs or FCM). Implement application(_:didReceiveRemoteNotification:fetchCompletionHandler:) to dismiss the CallKit screen immediately:

func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let event = userInfo["event"] as? String,
event == "call.canceled" || event == "call.ended",
let callId = userInfo["call_id"] as? String else {
completionHandler(.noData)
return
}

// Dismiss CallKit if this call is currently ringing
if let session = CallSessionStore.shared.activeSession, session.callId == callId {
let callUUID = UUID(uuidString: callId) ?? session.uuid
CallKitManager.shared.cxProvider.reportCall(
with: callUUID,
endedAt: Date(),
reason: .remoteEnded
)
CallSessionStore.shared.clear()
completionHandler(.newData)
return
}

completionHandler(.noData)
}

Android Implementation (Firebase Cloud Messaging)​

1. Receive FCM Data Messages (call.ring, call.canceled, call.ended)​

In your FirebaseMessagingService implementation, handle incoming call alerts as well as background cancellation events:

class FiretellFirebaseMessagingService : FirebaseMessagingService() {

override fun onNewToken(token: String) {
super.onNewToken(token)
// Send updated token to Firetell Agent API: POST /me/devices/voip-push-token
DeviceRepository.registerVoipPushToken(token)
}

override fun onMessageReceived(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val event = data["event"]

when (event) {
"call.ring" -> {
val callId = data["call_id"] ?: return
val callToken = data["call_token"] ?: ""
val wsUrl = data["ws_url"] ?: ""
val ringTimeoutSecs = data["ring_timeout_secs"]?.toIntOrNull() ?: 30
val callerNumber = data["caller_number"] ?: "Unknown"
val callerName = data["caller_name"] ?: callerNumber
val isVideo = data["is_video"]?.toBooleanStrictOrNull() ?: false

// Launch incoming call UI (Full Screen Intent / Telecom ConnectionService)
IncomingCallNotificationManager.showIncomingCall(
context = this,
callId = callId,
callerName = callerName,
callerNumber = callerNumber,
callToken = callToken,
wsUrl = wsUrl,
ringTimeoutSecs = ringTimeoutSecs,
isVideo = isVideo
)
}

"call.canceled", "call.ended" -> {
val callId = data["call_id"] ?: return
val reason = data["reason"] ?: "caller_hangup"

// Dismiss ringing notification / close Telecom ConnectionService immediately
IncomingCallNotificationManager.dismissIncomingCall(
context = this,
callId = callId,
reason = reason
)
}
}
}
}

Call Ring Timeout & Dismissal Architecture​

Because mobile networks and app background states vary, Firetell employs a three-tier dismissal architecture to guarantee that ringing screens never linger indefinitely when a call is canceled or answered elsewhere:

1. Push-Based Background Dismissal (call.canceled & call.ended)​

When a call is abandoned by the caller, forwarded to another agent, or answered by a peer in a ring group, Firetell sends a high-priority normal/background push (event: "call.canceled" or "call.ended").

  • iOS: Delivered via silent APNs background notification (content-available: 1) to dismiss CallKit via .remoteEnded.
  • Android: Delivered via high-priority FCM data message to dismiss the full-screen intent / Telecom call.

This works even if the mobile app has no active WebSocket or SSE connection in the background.

2. Real-time Stream Dismissal (SSE & WebSocket)​

If the mobile client is in the foreground and maintains an active real-time connection (Server-Sent Events stream or open signaling WebSocket), the server immediately broadcasts call.canceled over the connection, allowing instant sub-10ms UI dismissal.

3. Autonomous Offline Fallback (ring_timeout_secs)​

If the mobile device loses all network connectivity or goes into airplane mode while ringing:

  1. The device relies on the ring_timeout_secs parameter from the initial call.ring payload.
  2. The local countdown timer expires (e.g. after 30 seconds).
  3. The client automatically dismisses the incoming call UI locally with reason .unanswered.
VoIP Push Received ────────────────────> Local Timer Started (ring_timeout_secs)
│ │
├──────> Agent Answers: Stop timer & connect WS │
│ │
├──────> Background Push `call.canceled` / `call.ended` ──┤──> End CallKit / Telecom UI immediately
│ │
├──────> Realtime SSE / WS `call.canceled` received ──────┤──> End CallKit / Telecom UI immediately
│ │
└──────> Offline Timeout Reached: Auto-end CallKit UI <───┘
tip

Dynamic Timeout per Node

The ring_timeout_secs value is not static; it is defined dynamically in the workspace's Call Flow Builder. A VIP queue node may specify a 15-second ring timeout per agent, while an overflow queue may use 45 seconds. Always rely on the ring_timeout_secs field from the push payload rather than hardcoding a timer.


Establishing Call Audio (WebRTC Signaling)​

After accepting the call via CallKit / Android Telecom, the client completes the call setup over the WebSocket protocol:

  1. Connect: Establish a native WebSocket connection to the ws_url received in the push.
  2. Authenticate: Send the session.connect message within 3 seconds using call_token:
    {
    "event": "session.connect",
    "data": {
    "token": "YOUR_CALL_TOKEN"
    }
    }
  3. Receive SDP Offer: The server responds with session.connected followed by call.offer:
    {
    "event": "call.offer",
    "data": {
    "call_id": "cl_a1b2c3d4e5f6g7h8i9j0k1",
    "sdp": {
    "type": "offer",
    "sdp": "v=0\r\no=- ..."
    }
    }
    }
  4. Send SDP Answer: The mobile client sets the remote description on its WebRTC peer connection, creates an answer, and sends call.answer:
    {
    "event": "call.answer",
    "data": {
    "call_id": "cl_a1b2c3d4e5f6g7h8i9j0k1",
    "sdp": {
    "type": "answer",
    "sdp": "v=0\r\no=- ..."
    }
    }
    }

For comprehensive details on ICE candidate exchange, mute/hold operations, and hangup handling, see the Calls & WebSocket Signaling reference.


Best Practices & Troubleshooting​

APNs VoIP Entitlements & Background Modes​

  • Ensure your iOS project has enabled Voice over IP background mode (UIBackgroundModes containing voip).
  • Ensure your App ID has the Push Notifications and VoIP Push capabilities active in the Apple Developer portal.

APNs Environment Mismatch​

  • Tokens generated in Xcode debug builds on physical devices belong to the Sandbox environment.
  • Tokens from TestFlight and App Store builds belong to the Production environment.
  • If an agent does not receive pushes, verify that the workspace configuration matches the deployment build environment (sandbox vs production).

Ghost Rings Prevention​

  • Firetell configures APNs notification expiration (expiry) and FCM ttl to exactly match ring_timeout_secs. If delivery is delayed by the cellular carrier beyond the ring window, APNs and FCM will drop the push rather than ringing a stale, dead call.
  • Always implement the local countdown timer using ring_timeout_secs as described in Autonomous Offline Fallback.