Integrations / Flutter

Flutter

This guide adds Turnix to a Flutter app that uses 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.

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.

Integrate Turnix into a Flutter app
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.
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
  • A backend the app already talks to. The example below uses Dart with shelf; any language works, see the Quickstart for Node.js and Python.

1. Add a credentials endpoint to your backend#

The backend calls Turnix with the turnix_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.

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 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#

Last updated October 30, 2026