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

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:

  1. Decodes the JWT to extract agent identity
  2. Fetches workspace metadata (ICE servers, WS URLs) via REST
  3. Connects the SSE real-time event stream
  4. 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();