> ## Documentation Index
> Fetch the complete documentation index at: https://www.vobiz.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Vobiz Flutter Voice SDK

> Add Vobiz voice calling to Flutter apps on Android: sign in with access tokens, make and receive calls, hold, mute, DTMF, call quality metrics and push for closed apps.

Add Vobiz voice calls to a Flutter app on Android. The `vobiz_webrtc` SDK signs in to Vobiz over SIP on a WebSocket and carries the audio over WebRTC.

**Source code, API reference and example app:** [vobiz-ai/vobiz-flutter-sdk](https://github.com/vobiz-ai/vobiz-flutter-sdk)

> [!WARNING]
>
> Never put your Auth Token in the app. Your server keeps it and gives each signed-in user a short-lived access token.

## Features

* Make and receive calls, to phone numbers and SIP endpoints
* Hold, mute, loudspeaker and DTMF
* Call quality metrics: MOS, jitter, round-trip time and packet loss
* Sign-in with short-lived access tokens, renewed without dropping a call
* Caller ID, call recording and custom `X-VH-` SIP headers
* Calls that survive short network drops
* Incoming calls while the app is closed, through Firebase push
* Conference calls, through your server

## Usage

### Credentials

To make and receive calls with the SDK, you need:

1. A [Vobiz Console](https://console.vobiz.ai) account.
2. Your **Auth ID** and **Auth Token**, from **API Credentials** on the [dashboard](https://console.vobiz.ai/app/dashboard). They stay on your server.
3. An application in [Voice Applications](https://console.vobiz.ai/app/voice/applications) whose **Answer URL** is on your server. Copy its **Application ID**: calls that the app places go to this Answer URL.
4. A Vobiz number from [Numbers](https://console.vobiz.ai/app/numbers), to use as the caller ID.

Your server uses these to mint an access token for each signed-in user. See [Sign in to the Vobiz client](#sign-in-to-the-vobiz-client).

### Install the SDK

```yaml pubspec.yaml theme={null}
dependencies:
  vobiz_webrtc:
    git:
      url: https://github.com/vobiz-ai/vobiz-flutter-sdk.git
```

Then run `flutter pub get`. The SDK needs Flutter 3.19 or later and Android 7.0 (API 24) or later.

### Android configuration

Add these permissions to `android/app/src/main/AndroidManifest.xml`:

```xml AndroidManifest.xml theme={null}
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
```

Android asks the user for the microphone when the first call starts. `BLUETOOTH_CONNECT` is a runtime permission on Android 12 and later: ask for it before you send audio to a headset.

## Basic usage

### Vobiz client

`VobizClient` is the core class of the SDK. It signs in, places and answers calls, and reports what happens on streams.

```dart theme={null}
import 'package:vobiz_webrtc/vobiz_webrtc.dart';

final client = VobizClient();
```

To change the defaults, pass an `SdkConfig`, for example `VobizClient(config: const SdkConfig(autoReconnect: false))`:

| Field | Default | What it does |
| - | - | - |
| `autoReconnect` | `true` | Reconnects after a network drop, with backoff |
| `debug` | `false` | Prints SDK logs to the console |
| `iceConfig` | Google STUN | Add a TURN server here for restrictive networks |
| `reregisterOnForeground` | `true` | Registers again each time the app returns to the foreground |

### Logging configuration

Turn on `debug` to print SDK logs while you develop, or pass a `Logger` to send every line to your own logging:

```dart Console theme={null}
final client = VobizClient(config: const SdkConfig(debug: true));
```

```dart Your logger theme={null}
final client = VobizClient(
  logger: Logger(writer: (line) => yourLogger.info(line)),
);
```

Each line starts with `[INFO]`, `[WARN]` or `[ERROR]`. The SDK never logs access tokens.

### Sign in to the Vobiz client

Sign each user in with a short-lived access token that your server mints. The SDK asks your server for a new token before the current one expires, and registers again without dropping a call.

**1. Mint the token on your server.** Behind your own user authentication, call the Vobiz API with your Auth ID and Auth Token, read from the server's environment:

```js server.js theme={null}
// Node 18+ and Express. requireUser is your own auth middleware.
app.post('/voice/token', requireUser, async (req, res) => {
  const { VOBIZ_AUTH_ID, VOBIZ_AUTH_TOKEN, VOBIZ_APP_ID } = process.env;
  const url = `https://api.vobiz.ai/api/v1/Account/${VOBIZ_AUTH_ID}/JWT/Token/`;
  // A minute in the past: see the tip below.
  const nbf = Math.floor(Date.now() / 1000) - 60;

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Auth-ID': VOBIZ_AUTH_ID,
      'X-Auth-Token': VOBIZ_AUTH_TOKEN,
    },
    body: JSON.stringify({
      iss: VOBIZ_AUTH_ID,
      sub: req.user.id,  // letters, digits, _ or -, up to 50
      nbf,
      exp: nbf + 3600,   // 3 minutes to 24 hours after nbf
      app: VOBIZ_APP_ID, // its Answer URL handles the app's calls
      per: { voice: { incoming_allow: true, outgoing_allow: true } },
    }),
  });
  if (!response.ok) return res.sendStatus(502);

  const { token } = await response.json();
  res.json({ token });
});
```

The app then registers as `<sub>_<auth_id>`, for example `alice_MA_XXXXXXXX`.

> [!TIP]
>
> Always set `app`: without it, the registrar can refuse the token with `10001 INVALID_ACCESS_TOKEN`. Set `nbf` a minute in the past, because the registrar refuses a token that it thinks isn't valid yet (`10005 ACCESS_TOKEN_NOT_VALID_YET`).

**2. Sign in from the app.** `VobizBackendAccessTokenProvider` posts `{"reason": ...}` to your token URL and reads `{"token": ...}` from the reply:

```dart theme={null}
final tokens = VobizBackendAccessTokenProvider(
  url: Uri.parse(const String.fromEnvironment('VOBIZ_TOKEN_URL')),
  // Your app's own session, so your server knows who is signing in.
  headers: () async => {'Authorization': await yourAuthHeader()},
);
final session = await client.connectWithAccessTokenProvider(tokens.call);

client.connectionEvents.listen((event) {
  if (event.registrationState == RegistrationState.registered) {
    // Signed in: you can make and receive calls.
  }
});
```

Pass the token URL at build time, so it isn't written into your code:

```bash theme={null}
flutter run \
  --dart-define=VOBIZ_TOKEN_URL=https://your-server.example.com/voice/token
```

Registration finishes after the sign-in call returns, so wait for `RegistrationState.registered` before you place a call. When the user signs out, call `session.dispose()` and then `client.disconnect()`.

The client has four ways to sign in:

| Method | Use it for |
| - | - |
| `connectWithAccessTokenProvider(provider)` | Production: tokens from your server, renewed automatically |
| `connectWithAccessToken(token)` | A token you already have. Renew it yourself with `updateAccessToken(token)` |
| `connectWithCredentialsProvider(provider)` | Production with one [SIP endpoint](https://www.vobiz.ai/docs/platform/voice/endpoints.md) per user: `VobizBackendCredentialsProvider` fetches its username and password from your server |
| `connectWithCredentials(CredentialConfig(username: ..., password: ...))` | An endpoint username and password that you already hold |

### Make a call

`makeCall` places a call and returns its ID:

```dart theme={null}
final callId = await client.makeCall(
  '+919876543210',                     // destination, E.164
  callerId: '+918000000000',           // your number (X-VH-Caller-ID)
  headers: {'X-VH-Order-Id': 'A1234'}, // optional custom headers
  record: true,                        // optional (X-VH-Record: true)
);
```

Vobiz then requests your application's Answer URL, with `To` set to the number that the app dialled and the `X-VH-` headers included. Your Answer URL decides what the call does. For example, it can dial the number, using `X-VH-Caller-ID` as the caller ID:

```xml theme={null}
<Response>
  <Dial callerId="+918000000000">
    <Number>+919876543210</Number>
  </Dial>
</Response>
```

Calls to phone numbers are billed to your account. `makeCall` throws an `ArgumentError` for an invalid header or caller ID before anything is dialled. See [Custom SIP headers](https://www.vobiz.ai/docs/xml/request/sip-headers.md) for the header rules.

### Accept a call

Listen to `callEvents` to know when a call rings, connects or ends. Then accept the call from your answer button:

```dart theme={null}
client.callEvents.listen((event) {
  switch (event.state) {
    case CallStateStatus.incoming:
      // Ringing: event.remoteIdentity is the caller.
      break;
    case CallStateStatus.active:
      // Connected. event.callUuid is the Vobiz CallUUID.
      break;
    case CallStateStatus.failed:
      // event.sipCode and event.reason say why.
      break;
    case CallStateStatus.ended:
      // The call is over.
      break;
    default:
      break;
  }
});

await client.acceptCall();
```

To ring the app, answer the inbound call on your number with a `<Dial>` to the user's SIP identity:

```xml theme={null}
<Response>
  <Dial>
    <User>sip:alice_MA_XXXXXXXX@registrar.vobiz.ai</User>
  </Dial>
</Response>
```

To ring an endpoint, dial its SIP URI instead. See [`<User>`](https://www.vobiz.ai/docs/xml/dial/user).

### Decline or end a call

```dart theme={null}
await client.rejectCall(); // declines a ringing call (486 Busy Here)
await client.hangup();     // ends the current call
```

### DTMF (dual-tone multi-frequency)

```dart theme={null}
await client.sendDtmf('1'); // 0-9, *, # and A-D
```

The SDK sends the tones in the audio (RFC 4733) when the call supports it, and as SIP INFO otherwise.

### Mute a call

```dart theme={null}
await client.mute();
await client.unmute();
```

`client.isMuted` gives the current state.

### Toggle the loudspeaker

```dart theme={null}
await client.setSpeakerphone(true); // false for the earpiece
```

For Bluetooth and wired headsets, `client.audioRoutes` lists the outputs (`availableRoutes()`) and switches between them (`selectRoute(route)`).

### Put a call on hold

```dart theme={null}
await client.hold();
await client.resume();
```

If Vobiz refuses, the method throws a `VobizException` (`hold_failed` or `resume_failed`), and the call carries on as it was.

## Call quality metrics

Once audio flows, the SDK measures the call every 2 seconds and reports a `CallQualityEvent` on `callMediaEvents`. You don't need to turn anything on:

```dart theme={null}
client.callMediaEvents.listen((event) {
  if (event is! CallQualityEvent) return;
  final sample = event.sample;
  debugPrint('MOS ${sample.mos.toStringAsFixed(1)} (${sample.level.name}), '
      'jitter ${sample.jitterMs} ms, RTT ${sample.rttMs} ms');
  if (event.levelChanged && event.level == CallQualityLevel.poor) {
    // Show a "poor network" warning.
  }
});
```

To read the latest figures at any time, call `client.getCallStats()`. It returns `rttMs`, `jitterMs` and `packetLossPercent`.

### `CallQualitySample` properties

| Property | Type | Description |
| - | - | - |
| `mos` | `double` | Estimated mean opinion score, from 1 (bad) to 4.5 (excellent) |
| `level` | `CallQualityLevel` | Rating based on `mos` |
| `rttMs` | `double?` | Round-trip time to the Vobiz media server, in milliseconds |
| `jitterMs` | `double?` | Inbound jitter, in milliseconds |
| `packetLossPercent` | `double?` | Inbound packet loss since the previous sample |
| `codec` | `String?` | Audio codec in use, such as `PCMU` or `opus` |

A value is `null` until WebRTC has measured it. `mos` is an estimate from a simplified ITU-T G.107 E-model, not a measurement.

### `CallQualityLevel` enum

| Value | MOS | Description |
| - | - | - |
| `good` | 3.6 or higher | Toll quality |
| `fair` | 3.1 to 3.6 | Noticeable, but usable |
| `poor` | Below 3.1 | Many users will notice: show a network warning |

`levelChanged` is `true` only when the level moves, and the level moves only after two samples in a row agree, so a warning doesn't flicker.

## Advanced usage: push notifications

### How push works on Vobiz

The SDK signs in over a WebSocket. When Android closes your app, the socket closes, and Vobiz can't ring the app. To ring a closed app, your server answers the call and wakes the app with a Firebase Cloud Messaging (FCM) push:

1. A call reaches your Vobiz number, and Vobiz requests your server's Answer URL.

2. Your server sends the user's devices a high-priority FCM data message.

3. The app shows the system call screen and signs in. Your server waits for that, then dials the user with a `PushRef` SIP header set to the push's `call_id`:

   ```xml theme={null}
   <Dial>
     <User sipHeaders="PushRefX-VH=CALL_ID">sip:alice_MA_XXXXXXXX@registrar.vobiz.ai</User>
   </Dial>
   ```

4. If the caller hangs up first, your server sends a `call_ended` message.

The example push server in the SDK repository (`example/push_bridge`) does all four steps. Its push data has these keys, and every value is a string:

| Key | Value |
| - | - |
| `type` | `incoming_call` or `call_ended` |
| `call_id` | Your reference for the call, also sent as the `PushRef` header |
| `caller` | Who is calling, for the call screen |
| `callee` | The number that was called |
| `ttl_ms` | Milliseconds until you stop ringing, counted from when the push arrives |
| `line_busy` | `1` when the user is already on a call |

### Add push notifications to your Android app

The SDK reads the push (`VobizIncomingPush`) and finds the call that it announced. The call screen in these steps comes from the [flutter\_callkit\_incoming](https://pub.dev/packages/flutter_callkit_incoming) plugin, as in the SDK's example app.

1. **Add Firebase to your app.** Create a Firebase project, run `flutterfire configure` to add its configuration, and then add the packages:

   ```bash theme={null}
   flutter pub add firebase_core firebase_messaging flutter_callkit_incoming
   ```

2. **Send the device's FCM token to your server**, with the signed-in user, so your server knows where to push:

   ```dart theme={null}
   final fcmToken = await FirebaseMessaging.instance.getToken();
   // Send fcmToken to your server.
   FirebaseMessaging.instance.onTokenRefresh.listen(sendTokenToYourServer);
   ```

3. **Show the call screen when a push arrives.** Register one top-level handler for pushes in the background and in the foreground:

   ```dart main.dart theme={null}
   import 'package:firebase_core/firebase_core.dart';
   import 'package:firebase_messaging/firebase_messaging.dart';
   import 'package:flutter/widgets.dart';
   import 'package:flutter_callkit_incoming/entities/entities.dart' as ck;
   import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart' as ck;
   import 'package:vobiz_webrtc/vobiz_webrtc.dart';

   @pragma('vm:entry-point')
   Future<void> showIncomingCall(RemoteMessage message) async {
     final push = VobizIncomingPush.tryParse(message.data);
     if (push == null) return;
     if (push.kind == VobizPushKind.callEnded) {
       await ck.FlutterCallkitIncoming.endCall(push.callId);
       return;
     }
     if (push.isExpired()) return; // too late: the caller has gone
     await ck.FlutterCallkitIncoming.showCallkitIncoming(ck.CallKitParams(
       id: push.callId,
       nameCaller: push.caller,
       type: 0, // audio
       duration: 45000,
       extra: message.data,
     ));
   }

   Future<void> main() async {
     WidgetsFlutterBinding.ensureInitialized();
     await Firebase.initializeApp();
     FirebaseMessaging.onBackgroundMessage(showIncomingCall);
     FirebaseMessaging.onMessage.listen(showIncomingCall);
     runApp(const MyApp());
   }
   ```

   The `ck` prefix keeps the plugin's `CallEvent` apart from the SDK's.

4. **Answer the call that the user accepted.** **Accept** on the call screen opens your app. Sign in, then answer the INVITE whose `PushRef` matches the push, and only that one:

   ```dart theme={null}
   VobizIncomingPush? accepted;

   VobizIncomingPush? pushFrom(Map<dynamic, dynamic>? extra) {
     if (extra == null) return null;
     return VobizIncomingPush.tryParse(
         {for (final e in extra.entries) '${e.key}': e.value});
   }

   Future<void> takeAcceptedCall(Map<dynamic, dynamic>? extra) async {
     accepted = pushFrom(extra);
     // Sign in as shown above, unless the app is signed in already.
     if (accepted != null && !client.isConnected) await signIn();
   }

   client.callEvents.listen((event) {
     final push = accepted;
     if (event.state == CallStateStatus.incoming &&
         push != null &&
         push.matchesIncomingCall(headers: event.headers)) {
       accepted = null;
       client.acceptCall(event.callId);
     }
   });

   // The app was closed, and Accept started it.
   for (final call in await ck.FlutterCallkitIncoming.activeCalls()) {
     if (call.isAccepted) await takeAcceptedCall(call.extra);
   }

   // The app was already running.
   ck.FlutterCallkitIncoming.onEvent.listen((event) async {
     switch (event) {
       case ck.CallEventActionCallAccept(:final callKitParams):
         await takeAcceptedCall(callKitParams.extra);
       case ck.CallEventActionCallDecline(:final callKitParams):
         await declineOnYourServer(callKitParams.id); // see step 5
       default:
         break;
     }
   });
   ```

5. **Report a decline.** The app hasn't signed in, so it can't reject the call itself. When the user taps **Decline**, send the push's `call_id` to your server and have it hang up the caller. The example push server's `POST /push/decline` does this. Also stop ringing on your server after `ttl_ms`, in case the decline never arrives.

### Best practices for push notifications on Android

1. **Ask for permissions when the user signs in, not on the first ring.** Android 13 and later need the notification permission (`FirebaseMessaging.instance.requestPermission()`). On Android 14 and later, the call screen needs **Full screen notifications** to take over a locked screen: check it with `ck.FlutterCallkitIncoming.canUseFullScreenIntent()` and ask for it with `requestFullIntentPermission()`. Without it, the call shows as a heads-up notification.
2. **Test a release build, with the app swiped away.** Android delivers no pushes to a force-stopped app, and stopping a debug build from your IDE force-stops it.
3. **Handle late pushes.** A push can arrive after the caller has given up. `push.isExpired()` is `true` once `ttl_ms` has passed: show a missed call instead of ringing.
4. **Handle an early answer.** The user can tap **Accept** before the app has signed in. Keep the push, as step 4 does, and answer when the matching INVITE arrives. If it hasn't arrived within about 60 seconds, end the call screen with `ck.FlutterCallkitIncoming.endCall(id)`.
5. **Show call waiting when the user is on a call.** When `push.lineBusy` is `true`, show a notification instead of the full-screen call screen.
6. **Remove the camera permissions** that flutter\_callkit\_incoming adds if your app is audio-only. Otherwise, Play Console asks you to justify a camera foreground service:

   ```xml AndroidManifest.xml theme={null}
   <manifest xmlns:android="http://schemas.android.com/apk/res/android"
       xmlns:tools="http://schemas.android.com/tools">
       <uses-permission android:name="android.permission.CAMERA"
           tools:node="remove" />
       <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA"
           tools:node="remove" />
       <application>
           <service
               android:name="com.hiennv.flutter_callkit_incoming.CallkitNotificationService"
               android:foregroundServiceType="phoneCall|microphone"
               tools:replace="android:foregroundServiceType" />
       </application>
   </manifest>
   ```

## Conference calls

`VobizConference` turns the current call into a Vobiz conference room, so you can add people and manage them. Moving calls and dialling people need your Auth Token, so the SDK asks your server, which makes the Vobiz API calls:

```dart theme={null}
final conference = VobizConference(
  client: client,
  serverUrl: Uri.parse(const String.fromEnvironment('VOBIZ_SERVER_URL')),
  headers: () async => {'Authorization': await yourAuthHeader()},
);

await conference.add(number: '+919876543211', callerId: '+918000000000');
await conference.add(sipUser: 'bob_MA_XXXXXXXX');

conference.state.addListener(() {
  final people = conference.state.value?.present ?? const [];
  for (final person in people) {
    debugPrint('${person.label}: ${person.status.name}');
  }
});

await conference.mute(participantId); // also unmute, hold, resume, remove
await conference.end(); // ends it for everyone, this call included
```

The first `add` moves both sides of the call into a new room, and then dials the person in. People you add see `callerId`, which must be a number on your account. Your server implements four routes, and each one answers with the room's state:

| Route | Body |
| - | - |
| `POST /conference/add` | `call_uuid`, then `number` or `user`, and optionally `caller_id` |
| `POST /conference/state` | `call_uuid` |
| `POST /conference/participant` | `call_uuid`, `participant_id` and `action`: `mute`, `unmute`, `hold`, `resume` or `remove` |
| `POST /conference/end` | `call_uuid` |

The example push server in the SDK repository implements all four.

## Additional resources

* [SDK source, API reference and example app](https://github.com/vobiz-ai/vobiz-flutter-sdk)
* [SIP endpoints](https://www.vobiz.ai/docs/platform/voice/endpoints.md)
* [Custom SIP headers](https://www.vobiz.ai/docs/xml/request/sip-headers.md)
* [The `<User>` element](https://www.vobiz.ai/docs/xml/dial/user)
* [Vobiz WebRTC Playground](https://www.vobiz.ai/docs/integrations/webrtc-application-setup.md), for calls from a browser

Questions? Email [support@vobiz.ai](mailto:support@vobiz.ai).

## License

MIT. See [LICENSE](https://github.com/vobiz-ai/vobiz-flutter-sdk/blob/main/LICENSE).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.