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

VoIP Push Notifications

This guide covers how to wake your Flutter app for incoming calls when it is in the background or completely terminated, using Firebase Cloud Messaging (FCM) on Android and APNs VoIP push on iOS.


Architecture​

Caller dials → Firetell Backend → Push Notification
│
┌──────────────────┴──────────────────┐
▼ ▼
FCM (Android) APNs VoIP (iOS)
│ │
▼ ▼
Background Handler PushKit Delegate
│ │
▼ ▼
flutter_callkit_incoming Native CallKit UI
(full-screen notification) (lock screen ring)
│ │
└──────────────────┬──────────────────┘
▼
User answers / declines
│
┌──────────────────┴──────────────────┐
▼ ▼
Answer: Decline:
Call(ws_url, call_token) rejectViaHttp(call_token)
→ connectSignaling() → POST /calls/{id}/reject
→ accept() → ~50ms ✅
→ audio flows ✅

Key insight: The push payload contains ws_url and call_token — everything needed to connect the call directly, without initializing a full FiretellClient or fetching workspace metadata.


Setup​

1. Firebase Configuration​

flutter pub add firebase_core firebase_messaging
flutterfire configure

2. Register Push Tokens​

Call this after client.ready resolves (after login):

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:firetell_flutter_sdk/firetell_flutter_sdk.dart';

Future<void> registerPushTokens(FiretellClient client) async {
// Request permission (required on iOS)
await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
criticalAlert: true,
);

final deviceId = await DeviceIdHelper.getOrCreate();
final isIOS = !kIsWeb && Platform.isIOS;

// 1. Obtain VoIP Push Token
// - iOS: Apple PushKit token (raw 64-character hex string) via flutter_callkit_incoming
// - Android: FCM High-Priority Data message token
String? voipToken;
if (isIOS) {
try {
final token = await FlutterCallkitIncoming.getDevicePushTokenVoIP();
if (token.isNotEmpty) voipToken = token;
} catch (e) {
debugPrint('Failed to get iOS VoIP token: $e');
}
} else {
voipToken = await FirebaseMessaging.instance.getToken();
}

// Register VoIP Push Token (triggers call.ring pushes)
if (voipToken != null && voipToken.isNotEmpty) {
await PushTokenService.registerVoipPushToken(
baseUrl: client.baseUrl,
jwt: client.jwt,
pushToken: voipToken,
deviceId: deviceId,
platform: isIOS ? 'ios' : 'android',
);
}

// 2. Register Notification Token (for call.canceled / call.ended background dismissals)
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken != null && fcmToken.isNotEmpty) {
await PushTokenService.registerNotificationPushToken(
baseUrl: client.baseUrl,
jwt: client.jwt,
notificationToken: fcmToken,
deviceId: deviceId,
platform: isIOS ? 'ios' : 'android',
);
}
}

3. Background Message Handler​

Add a top-level function in main.dart:

@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();

final event = message.data['event'] as String?;

if (event == 'call.ring') {
final params = CallRingParams.fromFcmData(message.data);
await _showNativeIncomingCall(params);
} else if (event == 'call.canceled' || event == 'call.ended') {
final callId = message.data['call_id'] as String?;
if (callId != null) {
await FlutterCallkitIncoming.endCall(callId);
}
}
}

void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);
runApp(const MyApp());
}

4. Show Native Incoming Call UI​

Future<void> _showNativeIncomingCall(CallRingParams params) async {
await FlutterCallkitIncoming.showCallkitIncoming(CallKitParams(
id: params.callId,
nameCaller: params.callerName.isNotEmpty
? params.callerName
: params.callerNumber,
handle: params.callerNumber,
type: params.isVideo ? 1 : 0,
duration: params.ringTimeoutSecs * 1000,
extra: params.toMap(), // ← CRITICAL: stores ws_url + call_token
ios: const IOSParams(
supportsVideo: false,
maximumCallGroups: 1,
maximumCallsPerCallGroup: 1,
audioSessionMode: 'voiceChat',
audioSessionActive: true,
audioSessionPreferredSampleRate: 44100.0,
audioSessionPreferredIOBufferDuration: 0.005,
),
android: const AndroidParams(
isShowFullLockedScreen: true,
),
notification: const NotificationParams(
showNotification: true,
isShowMissedCallNotification: true, // Automatically triggers missed call alert on timeout
),
));
}
caution

The extra: params.toMap() is critical. It stores the push payload (including ws_url and call_token) inside the CallKitParams so the answer handler can retrieve them later.

5. Handle Answer / Decline (Cold Start)​

Register a global CallKit event listener in main() before runApp():

void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(_backgroundHandler);

// Global listener — captures answer/decline even on cold start
FlutterCallkitIncoming.onEvent.listen((CallEvent? event) async {
if (event == null) return;
switch (event.event) {
case Event.actionCallAccept:
await _handleAnswer(event);
case Event.actionCallDecline:
await _handleDecline(event);
default:
break;
}
});

runApp(const MyApp());
}

Answering​

Future<void> _handleAnswer(CallEvent event) async {
final body = event.body as Map<String, dynamic>?;
final extra = body?['extra'] as Map<String, dynamic>?;
if (extra == null) return;

final params = CallRingParams.fromMap(extra);
final iceServers = await IceServerCache.load(); // cached workspace TURN servers

final call = Call(
iceServers: iceServers,
options: CallOptions(
to: params.calleeNumber,
from: params.callerNumber,
fromName: params.callerName,
),
);
call.callId = params.callId;

// Connect WS + accept — uses ws_url + call_token from push payload
await call.connectSignaling(params.wsUrl!, params.callToken);
await call.accept();
// Audio flows ✅ — navigate to call screen
}

Declining​

Future<void> _handleDecline(CallEvent event) async {
final body = event.body as Map<String, dynamic>?;
final extra = body?['extra'] as Map<String, dynamic>?;
if (extra == null) return;

final callId = body?['id']?.toString();
final callToken = extra['call_token'] as String?;
final wsUrl = extra['ws_url'] as String?;

if (callId != null && callToken != null && wsUrl != null) {
// Convert wss://host/ws → https://host
final uri = Uri.parse(wsUrl);
final baseUrl = '${uri.scheme == 'wss' ? 'https' : 'http'}://${uri.host}';

final call = Call(iceServers: []);
call.callId = callId;
await call.rejectViaHttp(baseUrl: baseUrl, callToken: callToken);
// Rejected in ~50ms ✅
}
}

Push Payload Format​

call.ring​

{
"event": "call.ring",
"call_id": "cl_abc123",
"call_token": "eyJhbGci...",
"ws_url": "wss://ws_abc.firetell.app/ws",
"from": {
"number": "+84901234567",
"name": "Nguyen Van A",
"avatar": "https://..."
},
"to": {
"number": "1001",
"name": "Agent Smith"
},
"is_video": false,
"is_transfer": false
}

call.canceled / call.ended​

{
"event": "call.canceled",
"call_id": "cl_abc123"
}

ICE Server Caching​

The SDK caches workspace ICE servers (including TURN credentials) to local storage. On cold start, push-originated calls use the cached servers for better connectivity.

First login:
FiretellClient → fetch metadata → ICE servers (with TURN)
→ IceServerCache.save() → SharedPreferences ✅

Cold start push answer:
IceServerCache.load() → cached TURN servers ✅
→ Call(iceServers: cachedServers) → connect → accept

Never logged in (no cache):
IceServerCache.load() → default STUN servers (Google, Cloudflare)

Logout — Unregister Tokens​

await PushTokenService.logout(
baseUrl: client.baseUrl,
jwt: client.jwt,
deviceId: await DeviceIdHelper.getOrCreate(),
);

This removes the device's push tokens server-side so it no longer receives incoming call pushes.