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.
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.
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:
- Your backend requests short-lived ICE credentials from Turnix with your API token.
- The Flutter app fetches those credentials from your backend and passes them to
createPeerConnection.
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_webrtcand 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.
dart pub add turnix_dart shelf shelf_router
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:
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#
flutter pub add flutter_webrtc http
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.
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:
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_webrtcneedsINTERNET,CAMERAandRECORD_AUDIOinAndroidManifest.xml. TURN uses ports 3478 (UDP/TCP) and 443 (TLS), so no extra network configuration is needed. - iOS / macOS: add
NSCameraUsageDescriptionandNSMicrophoneUsageDescriptiontoInfo.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
iceServersyou pass.
Verify that the relay works#
Force the connection through Turnix by allowing only relay candidates. If the call connects, TURN works:
final pc = await createPeerConnection({
'iceServers': _turn!.iceServers,
'iceTransportPolicy': 'relay', // testing only: remove for production
});
You can also check which path a live call uses:
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#
- Dart SDK reference
- Clients & rooms: name clients and rooms so sessions are easy to find in the dashboard
- Regions & geo-routing