# Flutter

This guide adds Turnix to a Flutter app that uses [`flutter_webrtc`](https://pub.dev/packages/flutter_webrtc), on Android, iOS, web and desktop. Without a TURN server, calls fail for users on mobile networks with carrier-grade NAT, corporate Wi-Fi and other restrictive networks.

> **Tip: Integrate in seconds with your AI agent**
>
> Open your Flutter project (and your backend, if it is a separate repository) in Claude Code, Cursor or GitHub Copilot, copy the prompt below and paste it in. The agent does every step of this guide for you. Review the diff before you commit.

**AI prompt: Integrate Turnix into a Flutter app**

```text
Add Turnix (managed TURN/STUN servers) to the Flutter WebRTC app in this workspace.

Context about Turnix:
- Endpoint: POST https://turnix.io/api/v1/credentials/ice
- Auth header: "Authorization: Bearer <token>". The token is a secret project API token. It must only be used on a server, read from the environment variable TURNIX_API_TOKEN. Never put it into the Flutter app (anything in an APK, IPA or web build can be extracted), never log it, never commit it.
- Optional body fields (JSON): ttl (integer seconds), room, initiator_client, receiver_client (letters, digits, "-" and "_" only), preferred_region or fixed_region (a region slug).
- Optional header X-TURN-CLIENT-IP: the end user's public IP, so Turnix picks the TURN region closest to the user instead of the server.
- Success (200) response: {"iceServers":[{"urls":["stun:stun.turnix.io:3478"]},{"username":"<uuid>","credential":"<hex>","urls":["turn:<region>.turnix.io:3478?transport=udp","turn:<region>.turnix.io:3478?transport=tcp","turns:<region>.turnix.io:443?transport=udp","turns:<region>.turnix.io:443?transport=tcp"]}],"expiresAt":"<ISO 8601 UTC timestamp>"}
- Errors are JSON with an "error" field: 401 (missing or unknown token), 422 (invalid parameter or region), 429 (usage limit reached).
- A connected call keeps working after expiresAt, but new relays (ICE restart, renegotiation) need valid credentials.
- For Dart backends there is the package turnix_dart (TurnixIO.getIceCredentials(apiToken:, ttl:, room:, initiatorClient:, clientIp:) returns iceServers and expiresAt).
- Docs: https://turnix.io/docs/integrations/flutter and https://turnix.io/docs/api-reference/credentials

Do the following:
1. Inspect the workspace. Find the Flutter code that calls createPeerConnection (flutter_webrtc), how the app already talks to its backend (base URL, auth, HTTP client), and the backend code. Follow the existing conventions. If there is no backend in the workspace, stop and ask me where the credentials endpoint should live; do not call Turnix from the app.
2. Backend: add an authenticated endpoint (for example GET /api/turn-credentials) that only signed-in users can call. It calls Turnix server-side with TURNIX_API_TOKEN, forwards the caller's public IP in X-TURN-CLIENT-IP (respect the trusted-proxy setup), sends ttl=3600, and sends room/initiator_client when the app has a call or user ID (sanitize to [A-Za-z0-9_-]). Use a 10-second timeout and return only {"iceServers": [...], "expiresAt": "..."}. Map Turnix 401 to 502 with a log line about an invalid token, 429 to 503 "TURN usage limit reached"; never include the token in errors or logs.
3. Flutter: add a small service that fetches the endpoint with the app's existing auth and keeps the result in memory (not in shared preferences). Before creating each RTCPeerConnection, use cached credentials only if expiresAt is more than 60 seconds away, otherwise fetch new ones, and pass them as {'iceServers': ...} to createPeerConnection.
4. Reconnects: where the app restarts ICE (network change, reconnect), refresh expired credentials first, call pc.setConfiguration({'iceServers': ...}) and then pc.restartIce(), and send a new offer through the existing signaling.
5. Platforms: make sure Android has INTERNET, CAMERA and RECORD_AUDIO permissions, iOS/macOS have NSCameraUsageDescription and NSMicrophoneUsageDescription, and macOS has the outgoing network client entitlement. Only add what is missing.
6. Configuration and tests: add TURNIX_API_TOKEN to the backend's .env.example (empty, with a comment) and README; add a backend test with a mocked Turnix API that checks the Bearer token and X-TURN-CLIENT-IP are sent and the token is not returned. If TURNIX_API_TOKEN is set in my environment, make one real curl call and show only the status code and the number of iceServers.
7. Tell me how to verify the relay: temporarily set 'iceTransportPolicy': 'relay' in a debug build; if the call connects, TURN works. Then summarize the files you changed and what I still need to do (setting TURNIX_API_TOKEN in production).
```

The setup has two parts:

1. **Your backend** requests short-lived ICE credentials from Turnix with your API token.
2. **The Flutter app** fetches those credentials from your backend and passes them to `createPeerConnection`.

> **Danger: Never put the API token in the app**
>
> Anything shipped inside an APK, IPA or web build can be extracted. The API token stays on your server; the app only ever receives `iceServers`, which expire on their own.

## Before you start

- A Flutter app with `flutter_webrtc` and working signaling (offer/answer and ICE candidate exchange between peers)
- A Turnix project and an [API token](https://turnix.io/docs/api-reference/authentication)
- A backend the app already talks to. The example below uses Dart with `shelf`; any language works, see the [Quickstart](https://turnix.io/docs/getting-started/quickstart) for Node.js and Python.

## 1. Add a credentials endpoint to your backend

The backend calls Turnix with the [`turnix_dart`](https://turnix.io/docs/sdks/dart) package and returns only the ICE servers. Protect the endpoint with your app's normal authentication, so only signed-in users can get credentials.

```bash
dart pub add turnix_dart shelf shelf_router
```

```dart
import 'dart:convert';
import 'dart:io';

import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';
import 'package:turnix_dart/turnix_dart.dart';

final turnixToken = Platform.environment['TURNIX_API_TOKEN']!;

Future<Response> turnCredentials(Request request) async {
  // TODO: check your app's session or JWT here and return 401 if missing.
  final userId = request.headers['x-user-id'] ?? 'anonymous';
  // The end user's public IP, so Turnix picks the region closest to them.
  // Behind a proxy, read it from your trusted X-Forwarded-For instead.
  final clientIp = (request.context['shelf.io.connection_info'] as HttpConnectionInfo?)?.remoteAddress.address;

  try {
    final creds = await TurnixIO.getIceCredentials(
      apiToken: turnixToken,
      ttl: 3600,
      initiatorClient: userId,
      clientIp: clientIp,
    );
    return Response.ok(
      jsonEncode({
        'iceServers': creds.iceServers
            .map((s) => {
                  'urls': s.urls,
                  if (s.username != null) 'username': s.username,
                  if (s.credential != null) 'credential': s.credential,
                })
            .toList(),
        'expiresAt': creds.expiresAt.toIso8601String(),
      }),
      headers: {'content-type': 'application/json'},
    );
  } catch (e) {
    // Do not forward Turnix errors (or the token) to the client.
    stderr.writeln('Turnix request failed: $e');
    return Response(503, body: 'TURN credentials unavailable');
  }
}

void main() async {
  final router = Router()..get('/api/turn-credentials', turnCredentials);
  await io.serve(router.call, InternetAddress.anyIPv4, 8080);
}
```

Test it:

```bash
TURNIX_API_TOKEN=... dart run bin/server.dart
curl -s http://localhost:8080/api/turn-credentials
```

The response contains a STUN entry and a TURN entry with `turn:` and `turns:` URLs, plus `expiresAt`.

## 2. Fetch the credentials in the app

```bash
flutter pub add flutter_webrtc http
```

```dart
import 'dart:convert';

import 'package:http/http.dart' as http;

class TurnCredentials {
  TurnCredentials(this.iceServers, this.expiresAt);

  final List<Map<String, dynamic>> iceServers;
  final DateTime expiresAt;

  bool get expiresSoon =>
      expiresAt.difference(DateTime.now().toUtc()) < const Duration(seconds: 60);
}

Future<TurnCredentials> fetchTurnCredentials(String authToken) async {
  final res = await http.get(
    Uri.parse('https://api.example.com/api/turn-credentials'),
    headers: {'Authorization': 'Bearer $authToken'}, // your app's own auth
  );
  if (res.statusCode != 200) {
    throw Exception('TURN credentials failed (${res.statusCode})');
  }
  final json = jsonDecode(res.body) as Map<String, dynamic>;
  return TurnCredentials(
    (json['iceServers'] as List).cast<Map<String, dynamic>>(),
    DateTime.parse(json['expiresAt'] as String),
  );
}
```

## 3. Create the peer connection

Fetch credentials right before creating each `RTCPeerConnection`. Do not store them in shared preferences; they expire.

```dart
import 'package:flutter_webrtc/flutter_webrtc.dart';

TurnCredentials? _turn;

Future<RTCPeerConnection> createCallConnection(String authToken) async {
  if (_turn == null || _turn!.expiresSoon) {
    _turn = await fetchTurnCredentials(authToken);
  }
  final pc = await createPeerConnection({
    'iceServers': _turn!.iceServers,
    'sdpSemantics': 'unified-plan',
  });
  // ...add local tracks, wire up onIceCandidate / onTrack to your signaling as before
  return pc;
}
```

Nothing else in your signaling changes: candidates from Turnix arrive through `onIceCandidate` like any other candidate.

## 4. Refresh for long calls and reconnects

Credentials are checked when a TURN allocation is created or refreshed. A call that is already connected keeps working after `expiresAt`, but an ICE restart (for example after the phone switches from Wi-Fi to mobile data) needs valid credentials. Before restarting ICE, load fresh ones:

```dart
Future<void> restartIce(RTCPeerConnection pc, String authToken) async {
  if (_turn == null || _turn!.expiresSoon) {
    _turn = await fetchTurnCredentials(authToken);
    await pc.setConfiguration({'iceServers': _turn!.iceServers});
  }
  await pc.restartIce();
  // then create and send a new offer through your signaling, as for the first offer
}
```

Use a TTL that covers your typical call setup time; see [Credentials & TTL](https://turnix.io/docs/concepts/credentials).

## Platform notes

- **Android:** `flutter_webrtc` needs `INTERNET`, `CAMERA` and `RECORD_AUDIO` in `AndroidManifest.xml`. TURN uses ports 3478 (UDP/TCP) and 443 (TLS), so no extra network configuration is needed.
- **iOS / macOS:** add `NSCameraUsageDescription` and `NSMicrophoneUsageDescription` to `Info.plist`. On macOS, also enable the *Outgoing Connections (Client)* entitlement, otherwise every ICE request fails silently.
- **Web:** works the same; the browser's WebRTC stack uses the `iceServers` you pass.

## Verify that the relay works

Force the connection through Turnix by allowing only relay candidates. If the call connects, TURN works:

```dart
final pc = await createPeerConnection({
  'iceServers': _turn!.iceServers,
  'iceTransportPolicy': 'relay', // testing only: remove for production
});
```

You can also check which path a live call uses:

```dart
final stats = await pc.getStats();
final relayed = stats.any((r) =>
    r.type == 'local-candidate' && r.values['candidateType'] == 'relay');
print('Using TURN relay: $relayed');
```

Relayed sessions then show up in the Turnix dashboard under **Sessions**, with traffic per client and room.

## Troubleshooting

| Symptom | Likely cause |
|---|---|
| Connects on Wi-Fi, fails on mobile data | No TURN servers in the config: check that `iceServers` from your backend reach `createPeerConnection` |
| `relay` test never connects | Expired credentials (check `expiresAt`), or the backend returned an error instead of credentials |
| Backend gets `401` from Turnix | Wrong or revoked API token |
| Backend gets `429` from Turnix | Your plan's [usage limit](https://turnix.io/docs/concepts/usage-limits) is reached |
| Works everywhere except some corporate networks | Those networks allow only port 443: make sure the `turns:` URLs are kept in `iceServers` |

## Next steps

- [Dart SDK reference](https://turnix.io/docs/sdks/dart)
- [Clients & rooms](https://turnix.io/docs/concepts/clients-rooms): name clients and rooms so sessions are easy to find in the dashboard
- [Regions & geo-routing](https://turnix.io/docs/concepts/regions)
