---
sidebar_position: 11
title: VoIP Push Incoming Calls
description: Complete developer guide to handling incoming voice calls on iOS (PushKit & CallKit) and Android (FCM) using Firetell VoIP Push Notifications.
---

# 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.

```mermaid
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`](/docs/rest-api/agent-api/account#register-voip-push-notification-token) endpoint in the Agent API:

```bash
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](/docs/rest-api/agent-api/account#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`](/docs/rest-api/agent-api/account#register-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`](/docs/rest-api/agent-api/account#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:

```json
{
  "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`:

```json
{
  "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`)

| Field               | Type                  | Description                                                                                                       |
| :------------------ | :-------------------- | :---------------------------------------------------------------------------------------------------------------- |
| `event`             | `string`              | Always `"call.ring"`. Identifies this push as an incoming call alert.                                             |
| `workspace_id`      | `string`              | The Firetell workspace identifier handling the call.                                                              |
| `call_id`           | `string`              | Unique identifier for the incoming call session.                                                                  |
| `call_token`        | `string`              | Short-lived JWT credential used to authenticate the signaling WebSocket.                                          |
| `ws_url`            | `string`              | The WebSocket endpoint of the signaling server assigned to this call.                                             |
| `ring_timeout_secs` | `number` \| `string`  | Ring timeout in seconds determined by the Call Flow node (default `30`). Use this to run a local countdown timer. |
| `caller_number`     | `string`              | Phone number or extension of the calling party.                                                                   |
| `caller_name`       | `string`              | Display name of the caller (e.g., contact name or VIP tag).                                                       |
| `caller_avatar`     | `string` \| `null`    | Avatar URL of the calling party, or `null` / empty string if unavailable (e.g., external PSTN caller).            |
| `callee_number`     | `string`              | Destination phone number, queue number, or agent extension.                                                       |
| `callee_name`       | `string`              | Display name of the callee or target team.                                                                        |
| `is_transfer`       | `boolean` \| `string` | `true` if this incoming call is being transferred by another agent or supervisor.                                 |
| `transfer_reason`   | `string` \| `null`    | Reason for transfer, or `null` / empty string if direct call.                                                     |
| `is_video`          | `boolean` \| `string` | `true` 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:

```json
{
  "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:

```json
{
  "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

| Field | Type | Description |
| :--- | :--- | :--- |
| `event` | `string` | `"call.canceled"` (canceled while ringing) or `"call.ended"` (call terminated). |
| `workspace_id` | `string` | The Firetell workspace identifier. |
| `call_id` | `string` | Unique identifier of the call session to dismiss. |
| `reason` | `string` \| `null` | Reason description (e.g. `"caller_hangup"`, `"timeout"`, `"answered_elsewhere"`, `"rejected"`). |
| `timestamp` | `string` | ISO-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

```swift
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

```swift
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`:

```swift
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**:
```http
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.
:::

```swift
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:

```swift
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:

```kotlin
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](/docs/rest-api/agent-api/realtime-events) 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`:
   ```json
   {
     "event": "session.connect",
     "data": {
       "token": "YOUR_CALL_TOKEN"
     }
   }
   ```
3. **Receive SDP Offer:** The server responds with `session.connected` followed by `call.offer`:
   ```json
   {
     "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`:
   ```json
   {
     "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](/docs/rest-api/agent-api/calls#native-websocket-signaling-protocol) 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](#3-autonomous-offline-fallback-ring_timeout_secs).
