Quickstart Guide
This guide walks through the core workflows of the Firetell Flutter SDK: connecting, making outbound calls, handling incoming calls (foreground + push), and controlling active calls.
Step 1: Initialize the Client
Create a FiretellClient with your agent JWT and workspace domain. Wait for the ready future to resolve before making calls.
import 'package:firetell_flutter_sdk/firetell_flutter_sdk.dart';
final client = FiretellClient(
jwt: 'your-agent-jwt-token',
domain: 'yourcompany.firetell.app',
);
try {
final session = await client.ready;
print('Authenticated as: ${session.username}');
} catch (e) {
print('Initialization failed: $e');
}
During initialization, the SDK:
- Decodes the JWT to extract agent identity
- Fetches workspace metadata (ICE servers, WS URLs) via REST
- Connects the SSE real-time event stream
- Caches ICE servers locally for push-originated calls
Step 2: Listen to Real-time Events (SSE)
Subscribe to streams on the client to respond to incoming calls, agent status updates, and connection state changes.
// Connection state
client.onConnectionState.listen((state) {
print('SSE: ${state.name}'); // connecting, connected, disconnected, reconnecting
});
// Incoming call ring notification (shows caller info before WebRTC connects)
client.onCallRing.listen((CallRingParams params) {
print('Incoming: ${params.callerName} (${params.callerNumber})');
// → Show Ringing UI & play ringtone
});
// Incoming call WebRTC offer ready to answer
client.onCallOffer.listen((Call call) {
print('Call offer ready: ${call.callId}');
// → Show Answer / Decline buttons
});
// Agent state changes in the workspace
client.onAgentState.listen((data) {
print('Agent ${data['username']} is now ${data['state']}');
});
// Errors
client.onError.listen((error) {
print('Client error: $error');
});
Step 3: Make an Outbound Call
Use makeOutboundCall() to initiate a call. The SDK handles WebRTC setup, Full ICE gathering, REST API call, and WebSocket signaling automatically.
final call = await client.makeOutboundCall(
to: '+84901234567', // Phone number, extension, or SIP URI
from: '84281234567', // Optional: outbound caller ID
isVideo: false,
);
// Monitor call lifecycle
call.onStateChange.listen((event) {
print('State: ${event.state}'); // initiated → ringing → active → ended
if (event.state == CallState.ended ||
event.state == CallState.error ||
event.state == CallState.cancel) {
print('Call terminated. Reason: ${event.reason}');
}
});
// Remote audio stream (plays automatically for audio calls)
call.onRemoteStream.listen((stream) {
if (stream != null) {
print('Remote audio stream received');
}
});
Step 4: Handle Incoming Calls
Foreground (SSE)
When the app is in the foreground, incoming calls arrive via the SSE stream:
client.onCallRing.listen((params) {
// Show incoming call UI with caller info
showIncomingCallUI(params.callerName, params.callerNumber);
});
client.onCallOffer.listen((call) async {
// User taps "Answer"
await call.accept();
// Audio is now flowing ✅
// User taps "Decline"
// await call.reject();
});
Background / Killed (VoIP Push)
When the app is in the background or terminated, calls arrive via push notifications:
// main.dart — top-level background handler
@pragma('vm:entry-point')
Future<void> _backgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
if (message.data['event'] == 'call.ring') {
final params = CallRingParams.fromFcmData(message.data);
// Show native CallKit (iOS) / full-screen notification (Android)
await FlutterCallkitIncoming.showCallkitIncoming(CallKitParams(
id: params.callId,
nameCaller: params.callerName,
handle: params.callerNumber,
extra: params.toMap(), // ← stores ws_url + call_token for answer handler
));
}
}
When the user answers from the lock screen:
// Uses ws_url + call_token from push payload directly — no FiretellClient needed
FlutterCallkitIncoming.onEvent.listen((event) async {
if (event?.event == Event.actionCallAccept) {
final extra = (event!.body as Map)['extra'] as Map<String, dynamic>;
final params = CallRingParams.fromMap(extra);
final iceServers = await IceServerCache.load(); // cached from last session
final call = Call(iceServers: iceServers);
call.callId = params.callId;
await call.connectSignaling(params.wsUrl!, params.callToken);
await call.accept();
// Audio flows ✅
}
});
For complete push notification setup, see VoIP Push Notifications.
Step 5: Call Controls
// Mute / Unmute
await call.mute();
await call.unmute();
await call.toggleMute();
// Hold / Unhold (SDP renegotiation)
await call.onhold();
await call.unhold();
// DTMF digits
call.sendDTMF('1');
call.sendDTMF('#');
// Transfer
await call.transfer('+1987654321', reason: 'Customer request');
// Hang up
await call.hangup();
Step 6: Register Push Tokens
Register push tokens after login so the backend can send VoIP push notifications for incoming calls:
final deviceId = await DeviceIdHelper.getOrCreate();
final fcmToken = await FirebaseMessaging.instance.getToken();
await PushTokenService.registerVoipPushToken(
baseUrl: client.baseUrl,
jwt: client.jwt,
pushToken: fcmToken!,
deviceId: deviceId,
platform: Platform.isIOS ? 'ios' : 'android',
);
Step 7: Logout
// Unregister push tokens + cleanup
await PushTokenService.logout(
baseUrl: client.baseUrl,
jwt: client.jwt,
deviceId: await DeviceIdHelper.getOrCreate(),
);
// Destroy client (hangs up active calls, closes SSE)
client.destroy();