Source: https://turnix.io/docs/getting-started # Introduction Turnix is a managed TURN and STUN service for WebRTC. Your backend asks the Turnix API for short-lived credentials, hands them to your clients, and the clients use Turnix servers to connect, even behind strict NATs and firewalls. > **Tip: AI ready: integrate in seconds** > > Using Claude Code, Cursor or GitHub Copilot? Copy a ready-made prompt from [Build with AI agents](https://turnix.io/docs/getting-started/ai-agents) and your agent adds Turnix to your app, including the backend endpoint, error handling and tests. There are prompts for web apps, [Flutter](https://turnix.io/docs/integrations/flutter), [Node.js](https://turnix.io/docs/sdks/javascript), [Nextcloud Talk](https://turnix.io/docs/integrations/nextcloud), [Jitsi Meet](https://turnix.io/docs/integrations/jitsi) and migrating from coturn. > > Prefer doing it yourself? The [Quickstart](https://turnix.io/docs/getting-started/quickstart) gets you to ICE servers in your `RTCPeerConnection` in about five minutes. ## Why WebRTC needs STUN and TURN WebRTC tries to connect peers directly. To find a working path, each peer gathers *ICE candidates*: | Candidate | Comes from | Works when | |-----------|-----------|------------| | **Host** | The device's own network interfaces | Both peers are on the same network | | **Server-reflexive** | A **STUN** server that tells the peer its public IP and port | NATs on both sides allow hole punching | | **Relay** | A **TURN** server that forwards the media | Always, as long as the peer can reach the TURN server | STUN is cheap: it only answers "what is my public address?". TURN carries the actual media when no direct path exists, for example on corporate networks, mobile carrier NAT (CGNAT) or networks that block UDP. Without TURN, a noticeable share of calls fails to connect or has one-way audio. You want both: STUN so most calls connect directly, TURN as the fallback that makes the rest work. ## How Turnix works ```text ┌──────────────┐ 1. POST /api/v1/credentials/ice ┌──────────────┐ │ Your backend │ ───────────────────────────────────▶ │ Turnix API │ │ (API token) │ ◀─────────────────────────────────── │ │ └──────┬───────┘ 2. iceServers + expiresAt └──────────────┘ │ 3. iceServers (no API token) ▼ ┌──────────────┐ 4. STUN / TURN (UDP, TCP, TLS) ┌──────────────┐ │ Browser / │ ───────────────────────────────────▶ │ Turnix TURN │ ◀── other peer │ mobile app │ │ .turnix.io └──────────────┘ └──────────────┘ ``` 1. Your backend calls [`POST /credentials/ice`](https://turnix.io/docs/api-reference/credentials) with a project API token. 2. Turnix picks a region (nearest to the user, or the one you ask for), creates a one-time username and password, and returns a ready-to-use `iceServers` array plus an `expiresAt` timestamp. 3. Your backend passes `iceServers` to the client. The API token never leaves your server. 4. The client passes `iceServers` to `RTCPeerConnection`. The browser uses STUN at `stun.turnix.io` and, when needed, relays through `.turnix.io`. Every credential request is recorded in your project, so you can see credentials, TURN sessions, clients, rooms and traffic in the [dashboard](https://turnix.io/app). ## Key terms | Term | Meaning | |------|---------| | **Organization** | Your account. Billing, plan and usage limits apply per organization. | | **Project** | A container for API tokens, credentials and statistics, for example one per app or environment. | | **API token** | A secret bearer token that belongs to one project. Use it from server-side code only. See [Authentication](https://turnix.io/docs/api-reference/authentication). | | **ICE credentials** | A short-lived TURN username/password plus the STUN and TURN URLs, in WebRTC's `iceServers` format. See [Credentials & TTL](https://turnix.io/docs/concepts/credentials). | | **Region** | A Turnix TURN location, such as `us-west`. See [Regions & geo-routing](https://turnix.io/docs/concepts/regions). | | **Clients and rooms** | Optional labels you attach to a credential request to trace calls in the dashboard. See [Clients & rooms](https://turnix.io/docs/concepts/clients-rooms). | | **Auth secret (LTC)** | A per-project shared secret for software that generates TURN credentials itself, such as Nextcloud Talk and Jitsi. See [Shared-secret credentials](https://turnix.io/docs/integrations/shared-secret). | ## Where to go next - [Build with AI agents](https://turnix.io/docs/getting-started/ai-agents): let your coding agent do the integration. - [Quickstart](https://turnix.io/docs/getting-started/quickstart): create a token and request your first credentials. - [API reference](https://turnix.io/docs/api-reference): every endpoint, parameter and error. - [SDKs](https://turnix.io/docs/sdks): thin wrappers for [Node.js](https://turnix.io/docs/sdks/javascript) and [Dart/Flutter](https://turnix.io/docs/sdks/dart). - [Integrations](https://turnix.io/docs/integrations): [Flutter](https://turnix.io/docs/integrations/flutter), [Nextcloud Talk](https://turnix.io/docs/integrations/nextcloud) and [Jitsi Meet](https://turnix.io/docs/integrations/jitsi). - [Pricing](https://turnix.io/#pricing): plans are based on relayed traffic. You can start for free. --- Source: https://turnix.io/docs/getting-started/quickstart # Quickstart In this guide you create an API token, request ICE credentials from your backend, and pass them to `RTCPeerConnection`. It takes about five minutes. You need a [Turnix account](https://turnix.io/signup) (the free plan is enough) and a backend that your WebRTC client already talks to. **AI prompt: Let your AI agent do the integration** ```text Add Turnix (managed TURN/STUN servers) to the WebRTC app in this repository. Context about Turnix: - Endpoint: POST https://turnix.io/api/v1/credentials/ice - Auth header: "Authorization: Bearer ". The token is a secret project API token. Read it from the environment variable TURNIX_API_TOKEN. Never send it to the browser or mobile client, never log it, never commit it. - Optional body fields (JSON or form-encoded): ttl (integer seconds, digits only), 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. Set it, because otherwise Turnix picks the TURN region closest to the backend server instead of the user. - Success (200) response: {"iceServers":[{"urls":["stun:stun.turnix.io:3478"]},{"username":"","credential":"","urls":["turn:.turnix.io:3478?transport=udp","turn:.turnix.io:3478?transport=tcp","turns:.turnix.io:443?transport=udp","turns:.turnix.io:443?transport=tcp"]}],"expiresAt":""} - Errors are JSON with an "error" field: 401 (missing or unknown token), 422 (invalid parameter, unknown or offline region), 429 (the organization's data usage limit is reached; body also has "message", "limit" and "used"). - Docs: https://turnix.io/docs/api-reference/credentials Do the following: 1. Inspect the repository. Find the backend framework, where the client creates RTCPeerConnection, and how the client already calls the backend (auth, fetch helper, base URL). Follow the existing conventions. If there is no backend, stop and ask me where the endpoint should live. 2. Backend: add an authenticated endpoint (for example GET /api/turn-credentials) that only signed-in users of this app can call. It calls the Turnix endpoint server-side with the token from TURNIX_API_TOKEN, forwards the caller's public IP in X-TURN-CLIENT-IP (respect the app's trusted-proxy setup when reading the IP), sends ttl=3600, and sends room/initiator_client when the app has a call or user identifier (sanitize to [A-Za-z0-9_-]). Use a 10-second timeout. Return only {"iceServers": [...], "expiresAt": "..."} to the client. 3. Error handling: if TURNIX_API_TOKEN is missing, fail at startup or return 500 with a clear log message. Map Turnix 401 to a 502 with a log line saying the token is invalid; pass 429 through as 503 with a "TURN usage limit reached" message; never include the token in errors or logs. 4. Frontend: before creating each RTCPeerConnection, fetch the new endpoint and pass iceServers into new RTCPeerConnection({ iceServers }). Keep any existing STUN servers only if the app needs them. If expiresAt has passed or is less than 60 seconds away when a new connection or ICE restart is needed, fetch fresh credentials first (for an ICE restart, call pc.setConfiguration({ ...pc.getConfiguration(), iceServers }) and then pc.restartIce()). Do not cache credentials in localStorage. 5. Configuration: add TURNIX_API_TOKEN to .env.example (or the project's equivalent) with an empty value and a comment, and document it in the README. Make sure .env is git-ignored. 6. Tests: add a backend test that mocks the Turnix API and checks that the endpoint sends the Bearer token and X-TURN-CLIENT-IP, returns iceServers, and does not expose the token. Then, if TURNIX_API_TOKEN is set in my environment, make one real call with curl to the Turnix endpoint and show me the status code and the number of iceServers (do not print the credentials). 7. Summarize the files you changed and anything I still need to do (for example, setting TURNIX_API_TOKEN in production). ``` ## 1. Create a project A project groups your API tokens, credentials and statistics. Most teams use one project per app and environment, for example `myapp-staging` and `myapp-production`. Billing and usage limits are shared across all projects in your organization. 1. Open [Projects](https://turnix.io/app/project) in the dashboard. 2. Create a project and make it the active project. ## 2. Create an API token 1. Open [API Tokens](https://turnix.io/app/project/tokens) for the active project. 2. Click **Create new API Token**, give it a name, and copy the token. 3. Store it as a secret on your server, for example in the `TURNIX_API_TOKEN` environment variable. > **Warning: Keep the token on your server** > > The token is shown only once, right after you create it. Anyone who has it can request credentials that count against your traffic. Never put it in browser code, mobile apps or public repositories. If it leaks, **remove** the token in the dashboard and create a new one. ## 3. Request ICE credentials from your backend Call `POST /credentials/ice` with the token. Pass the end user's IP in `X-TURN-CLIENT-IP` so Turnix picks the region closest to the user rather than to your server. **cURL** ```bash curl -X POST https://turnix.io/api/v1/credentials/ice \ -H "Authorization: Bearer $TURNIX_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ttl": 3600}' ``` **Node.js** ```javascript // Express example, Node.js 18+ (built-in fetch) app.get("/api/turn-credentials", requireLogin, async (req, res) => { const response = await fetch("https://turnix.io/api/v1/credentials/ice", { method: "POST", headers: { Authorization: `Bearer ${process.env.TURNIX_API_TOKEN}`, "Content-Type": "application/json", "X-TURN-CLIENT-IP": req.ip, // the end user's public IP }, body: JSON.stringify({ ttl: 3600 }), }); if (!response.ok) { console.error("Turnix error", response.status, await response.text()); return res.status(502).json({ error: "Could not get TURN credentials" }); } const { iceServers, expiresAt } = await response.json(); res.json({ iceServers, expiresAt }); }); ``` **Python** ```python # Flask example import os import requests from flask import jsonify, request @app.get("/api/turn-credentials") @login_required def turn_credentials(): response = requests.post( "https://turnix.io/api/v1/credentials/ice", headers={ "Authorization": f"Bearer {os.environ['TURNIX_API_TOKEN']}", "X-TURN-CLIENT-IP": request.remote_addr, # the end user's public IP }, json={"ttl": 3600}, timeout=10, ) if not response.ok: app.logger.error("Turnix error %s %s", response.status_code, response.text) return jsonify(error="Could not get TURN credentials"), 502 data = response.json() return jsonify(iceServers=data["iceServers"], expiresAt=data["expiresAt"]) ``` The response contains one STUN entry and one TURN entry with four transports: ```json { "iceServers": [ { "urls": ["stun:stun.turnix.io:3478"] }, { "username": "3f1c2b9e-8f4e-4d7a-9a51-2f6a0c7d1e42", "credential": "9b2f4c1d7e8a6b3c5d0e1f2a3b4c5d6e", "urls": [ "turn:us-west.turnix.io:3478?transport=udp", "turn:us-west.turnix.io:3478?transport=tcp", "turns:us-west.turnix.io:443?transport=udp", "turns:us-west.turnix.io:443?transport=tcp" ] } ], "expiresAt": "2026-10-30T13:00:00.000Z" } ``` ## 4. Pass the ICE servers to RTCPeerConnection In the browser, fetch the credentials from **your** endpoint and create the peer connection with them: ```javascript const res = await fetch("/api/turn-credentials", { credentials: "include" }); if (!res.ok) throw new Error("Could not get TURN credentials"); const { iceServers } = await res.json(); const pc = new RTCPeerConnection({ iceServers }); // ...add tracks, create the offer and exchange SDP through your signaling as usual ``` Request fresh credentials for every new connection. They expire at `expiresAt`; see [Credentials & TTL](https://turnix.io/docs/concepts/credentials) for how to choose a TTL and handle ICE restarts. ## 5. Check that TURN works 1. Open `chrome://webrtc-internals` (Chrome) or `about:webrtc` (Firefox) during a call. 2. Look for ICE candidates of type `relay` with an address on `.turnix.io`. If you see them, TURN works. 3. To force the relay path for testing, create the connection with `iceTransportPolicy: "relay"`: ```javascript const pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: "relay" }); ``` Credentials, TURN sessions and traffic for your project appear in the [dashboard](https://turnix.io/app). ## Troubleshooting | Symptom | Cause and fix | |---------|---------------| | `401 {"error":"Not Authorized"}` | The `Authorization` header is missing or does not start with `Bearer `. | | `401 {"error":"Token not found"}` | The token is wrong or was removed. Create a new one. | | `422 {"error":"TTL must be an integer"}` | Send `ttl` as whole seconds, digits only. | | `429 {"error":"Usage limit exceeded"}` | Your organization used its included traffic on a plan without overage (the free plan). Upgrade in [Billing](https://turnix.io/app/organization/billing). See [Usage, limits & billing](https://turnix.io/docs/concepts/usage-limits). | | No `relay` candidates | The credentials expired before the connection started (use a longer `ttl`), or UDP and port 3478 are blocked. The `turns:…:443?transport=tcp` URL is the most firewall-friendly option; make sure you pass all URLs from the response. | | Relay works but latency is high | The region was chosen from your server's location. Send the user's IP in `X-TURN-CLIENT-IP`, or set `preferred_region`. See [Regions & geo-routing](https://turnix.io/docs/concepts/regions). | ## Next steps - [Credentials API reference](https://turnix.io/docs/api-reference/credentials): every parameter and error. - [Clients & rooms](https://turnix.io/docs/concepts/clients-rooms): label credentials to trace calls in the dashboard. - [Node.js SDK](https://turnix.io/docs/sdks/javascript) and [Dart SDK](https://turnix.io/docs/sdks/dart): skip the HTTP boilerplate. --- Source: https://turnix.io/docs/getting-started/ai-agents # Build with AI agents Turnix is AI ready: integrate it in seconds. Coding agents such as Claude Code, Cursor or GitHub Copilot can do a Turnix integration for you, if they get accurate facts about the API. The prompts on this page give them exactly that: the endpoint, the auth rules, the response shape and the errors, plus a step-by-step task. | You have | Prompt | |---|---| | A web app with any backend | [Integrate Turnix into an existing WebRTC app](#integrate-turnix-into-an-existing-webrtc-app) (below) | | A self-hosted coturn | [Migrate from self-hosted coturn](#migrate-from-self-hosted-coturn) (below) | | Turnix running, want per-call tracking | [Add per-room client tracking](#add-per-room-client-tracking) (below) | | A Flutter app | [Flutter integration](https://turnix.io/docs/integrations/flutter) | | A Node.js backend | [JavaScript / Node.js SDK](https://turnix.io/docs/sdks/javascript) | | Nextcloud Talk | [Nextcloud Talk](https://turnix.io/docs/integrations/nextcloud) (agent runs on your server) | | Jitsi Meet | [Jitsi Meet](https://turnix.io/docs/integrations/jitsi) (agent runs on your server) | ## How to use these prompts 1. **Claude Code:** click **Copy for Claude Code** on one of the cards below and run the copied command in your project directory. It starts Claude Code with the prompt already loaded, for example: ```bash claude "$(curl -fsSL https://turnix.io/docs/prompts/integrate-turnix-into-an-existing-webrtc-app.txt)" ``` 2. **Cursor, GitHub Copilot or another agent:** open your project in the agent, click **Copy prompt** and paste it into the agent chat. 3. Set `TURNIX_API_TOKEN` in your local environment first if you want the agent to verify the integration with a real API call. Create a token under [API Tokens](https://turnix.io/app/project/tokens). 4. Review the diff. In particular, check that the token is read only on the server and never logged. > **Warning: Do not paste your token into the chat** > > The prompts refer to the `TURNIX_API_TOKEN` environment variable on purpose. Never paste the token itself into a prompt. ## Integrate Turnix into an existing WebRTC app The standard integration: a backend endpoint that requests credentials and a client that uses them. This is the same prompt as in the [Quickstart](https://turnix.io/docs/getting-started/quickstart). **AI prompt: Integrate Turnix into an existing WebRTC app** ```text Add Turnix (managed TURN/STUN servers) to the WebRTC app in this repository. Context about Turnix: - Endpoint: POST https://turnix.io/api/v1/credentials/ice - Auth header: "Authorization: Bearer ". The token is a secret project API token. Read it from the environment variable TURNIX_API_TOKEN. Never send it to the browser or mobile client, never log it, never commit it. - Optional body fields (JSON or form-encoded): ttl (integer seconds, digits only), 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. Set it, because otherwise Turnix picks the TURN region closest to the backend server instead of the user. - Success (200) response: {"iceServers":[{"urls":["stun:stun.turnix.io:3478"]},{"username":"","credential":"","urls":["turn:.turnix.io:3478?transport=udp","turn:.turnix.io:3478?transport=tcp","turns:.turnix.io:443?transport=udp","turns:.turnix.io:443?transport=tcp"]}],"expiresAt":""} - Errors are JSON with an "error" field: 401 (missing or unknown token), 422 (invalid parameter, unknown or offline region), 429 (the organization's data usage limit is reached; body also has "message", "limit" and "used"). - Docs: https://turnix.io/docs/api-reference/credentials Do the following: 1. Inspect the repository. Find the backend framework, where the client creates RTCPeerConnection, and how the client already calls the backend (auth, fetch helper, base URL). Follow the existing conventions. If there is no backend, stop and ask me where the endpoint should live. 2. Backend: add an authenticated endpoint (for example GET /api/turn-credentials) that only signed-in users of this app can call. It calls the Turnix endpoint server-side with the token from TURNIX_API_TOKEN, forwards the caller's public IP in X-TURN-CLIENT-IP (respect the app's trusted-proxy setup when reading the IP), sends ttl=3600, and sends room/initiator_client when the app has a call or user identifier (sanitize to [A-Za-z0-9_-]). Use a 10-second timeout. Return only {"iceServers": [...], "expiresAt": "..."} to the client. 3. Error handling: if TURNIX_API_TOKEN is missing, fail at startup or return 500 with a clear log message. Map Turnix 401 to a 502 with a log line saying the token is invalid; pass 429 through as 503 with a "TURN usage limit reached" message; never include the token in errors or logs. 4. Frontend: before creating each RTCPeerConnection, fetch the new endpoint and pass iceServers into new RTCPeerConnection({ iceServers }). Keep any existing STUN servers only if the app needs them. If expiresAt has passed or is less than 60 seconds away when a new connection or ICE restart is needed, fetch fresh credentials first (for an ICE restart, call pc.setConfiguration({ ...pc.getConfiguration(), iceServers }) and then pc.restartIce()). Do not cache credentials in localStorage. 5. Configuration: add TURNIX_API_TOKEN to .env.example (or the project's equivalent) with an empty value and a comment, and document it in the README. Make sure .env is git-ignored. 6. Tests: add a backend test that mocks the Turnix API and checks that the endpoint sends the Bearer token and X-TURN-CLIENT-IP, returns iceServers, and does not expose the token. Then, if TURNIX_API_TOKEN is set in my environment, make one real call with curl to the Turnix endpoint and show me the status code and the number of iceServers (do not print the credentials). 7. Summarize the files you changed and anything I still need to do (for example, setting TURNIX_API_TOKEN in production). ``` ## Migrate from self-hosted coturn Use this when your app currently points at your own coturn server, typically with a static user or a `static-auth-secret` and credentials generated in your code. **AI prompt: Migrate from self-hosted coturn to Turnix** ```text Migrate this application from a self-hosted coturn TURN server to Turnix (managed TURN/STUN). Context about Turnix: - Credentials endpoint: POST https://turnix.io/api/v1/credentials/ice with header "Authorization: Bearer ". The token is a secret project API token in the environment variable TURNIX_API_TOKEN; use it only on the server, never log or commit it. - Optional body fields: ttl (integer seconds), room, initiator_client, receiver_client (letters, digits, "-" and "_" only), preferred_region or fixed_region (region slug). Optional header X-TURN-CLIENT-IP with the end user's public IP so the nearest region is chosen. - 200 response: {"iceServers":[{"urls":["stun:stun.turnix.io:3478"]},{"username":"","credential":"","urls":["turn:.turnix.io:3478?transport=udp","turn:.turnix.io:3478?transport=tcp","turns:.turnix.io:443?transport=udp","turns:.turnix.io:443?transport=tcp"]}],"expiresAt":""} - Errors: JSON {"error": "..."}; 401 bad/missing token, 422 invalid parameter or region, 429 usage limit reached. - Turnix also offers a per-project shared secret ("auth secret") for software that generates TURN REST API credentials itself (username = unix expiry timestamp, password = base64(HMAC-SHA1(secret, username))), with hosts .turnix.io on ports 3478 (turn, UDP/TCP) and 443 (turns). Prefer the credentials endpoint for code you control. - Docs: https://turnix.io/docs/api-reference/credentials and https://turnix.io/docs/integrations/shared-secret Do the following: 1. Find every place that references the old TURN/STUN setup: hard-coded turn:/stun: URLs, coturn usernames/passwords, static-auth-secret or HMAC credential generation, related environment variables, docker-compose services, deployment manifests and firewall docs. List them for me before changing anything. 2. Replace the credential generation with a server-side call to the Turnix credentials endpoint (one call per new peer connection, ttl=3600, forward the user's IP in X-TURN-CLIENT-IP, 10-second timeout). Return only iceServers and expiresAt to clients. Keep the existing client-facing response shape if other code depends on it, and adapt it from the Turnix response. 3. Remove the old static TURN usernames, passwords and secrets from code and config. Replace the related environment variables with TURNIX_API_TOKEN and update .env.example and the README. 4. Remove the coturn service from docker-compose or deployment files only if nothing else uses it, and tell me what you removed. Do not touch infrastructure outside this repository. 5. Handle errors without leaking the token: log Turnix 401 as a configuration error, surface 429 as "TURN usage limit reached". 6. Update or add tests that mock the Turnix endpoint. If TURNIX_API_TOKEN is set in my environment, make one real request and report the HTTP status and the TURN hostname in the response (not the username or credential). 7. Summarize the changes and the manual steps left (setting TURNIX_API_TOKEN in each environment, decommissioning the old server). ``` ## Add per-room client tracking Use this when Turnix already works and you want calls to show up per room and per participant in the dashboard. **AI prompt: Add Turnix client and room tracking** ```text This app already requests TURN credentials from Turnix (POST https://turnix.io/api/v1/credentials/ice, server-side, Bearer token from TURNIX_API_TOKEN). Add client and room labels to those requests so calls can be traced in the Turnix dashboard. Facts about the Turnix API: - Optional body fields: room, initiator_client, receiver_client. Each value may contain only letters, digits, "-" and "_" (regex ^[A-Za-z0-9_-]+$). Any other character makes the whole request fail with 422 {"error":" contains invalid characters"}. - Turnix creates a client or room the first time it sees a new name in the project and reuses it afterwards. Clients sent together with a room are linked to that room. - initiator_client is the participant that starts the connection (creates the offer), receiver_client the one that answers. For group calls, send only room and initiator_client (the requesting participant). - Labels are visible to everyone with access to the Turnix project. Do not send personal data such as email addresses or names; use internal IDs or opaque hashes. - Docs: https://turnix.io/docs/concepts/clients-rooms Do the following: 1. Find where the backend calls the Turnix credentials endpoint and where it knows the current user and the call/meeting/room identifier. 2. Add a small helper that turns an internal ID into a safe label: keep [A-Za-z0-9_-], replace everything else with "-", trim to 64 characters, and fall back to omitting the field if the result is empty. Use a stable, non-personal identifier (for example the user's database ID or a hash of it). 3. Send room= and initiator_client= with every credential request. If the app has a 1:1 call model where the backend knows the other participant, also send receiver_client. 4. Make sure the client-facing API does not let users choose arbitrary labels for other users. 5. Add unit tests for the helper (including emails, spaces, unicode and very long IDs) and a test that the Turnix request body contains the expected labels. 6. Summarize the change and where I can see the result in the dashboard (Rooms and Clients pages). ``` ## Give your agent the docs Agents produce better code when they can read the reference itself. The docs are built for that: - **[llms.txt](https://turnix.io/llms.txt)**: an index of all docs pages for AI tools ([llmstxt.org](https://llmstxt.org) format). [llms-full.txt](https://turnix.io/llms-full.txt) has every page in one file. - **Markdown version of every page**: append `.md` to any docs URL, for example [/docs/api-reference/credentials.md](https://turnix.io/docs/api-reference/credentials.md). The **Copy page** button at the top of each page copies it, or opens the page in Claude or ChatGPT. The most useful pages to give your agent: - [API overview](https://turnix.io/docs/api-reference): base URL, authentication, error format - [Authentication](https://turnix.io/docs/api-reference/authentication): API tokens and how to keep them safe - [Credentials](https://turnix.io/docs/api-reference/credentials): `POST /credentials/ice`, every parameter and error - [Regions](https://turnix.io/docs/api-reference/regions) and [Usage](https://turnix.io/docs/api-reference/usage) - [Credentials & TTL](https://turnix.io/docs/concepts/credentials): expiry and refresh behaviour - [Shared-secret credentials](https://turnix.io/docs/integrations/shared-secret): for coturn-style integrations --- Source: https://turnix.io/docs/concepts/credentials # Credentials & TTL Every call to [`POST /credentials/ice`](https://turnix.io/docs/api-reference/credentials) creates a new, single-purpose TURN credential. This page explains its lifecycle and how to choose a TTL. ## What a credential is A credential is a TURN username and password, bundled with the STUN and TURN URLs in WebRTC's `iceServers` format: - **username**: a random UUID, unique per request - **credential**: a random 32-character hex password - **urls**: `stun:stun.turnix.io:3478`, plus four TURN URLs on the selected region's host `.turnix.io` - **expiresAt**: the moment the credential stops being accepted, in ISO 8601 UTC Turnix stores the credential in your project together with the region, the request's country, and any [client and room labels](https://turnix.io/docs/concepts/clients-rooms). You can browse them under [Credentials](https://turnix.io/app/turn/credentials) in the dashboard. ## The TTL `ttl` is the credential's lifetime in seconds. Turnix sets `expiresAt = now + ttl`. | | | |---|---| | Format | Whole seconds, digits only (`3600`, not `3600.0` or `"1h"`). Anything else returns `422 {"error":"TTL must be an integer"}`. | | Default | When you omit `ttl`, the platform default applies: **30 seconds**. | | Bounds | The API does not enforce a minimum or maximum. A `ttl` of `0` creates a credential that has already expired. | > **Warning: The default is very short** > > 30 seconds is only enough if the client uses the credentials immediately. If there is any delay between fetching credentials and creating the peer connection (a lobby, a permission prompt, a slow signaling round trip), set `ttl` explicitly. ## When expiry matters The TURN server checks `expiresAt` when a client authenticates to create a relay allocation. That happens when ICE gathering starts, and again on every ICE restart, because a restart creates new allocations. A relay that was set up before `expiresAt` is not cut off when the credential expires; the call continues. What fails after expiry is setting up *new* relays with the same credential, for example a renegotiation or an ICE restart after a network change. ## Choosing a TTL | Scenario | Suggested `ttl` | |----------|-----------------| | Credentials fetched right before `new RTCPeerConnection()` | `300`–`600` | | Typical calls, credentials fetched when the user joins | `3600` | | Long sessions where you cannot refresh before an ICE restart | Up to the longest expected session, for example `14400` | Shorter is safer: a leaked credential can relay traffic, billed to you, until it expires. Longer is more forgiving of delays. Most apps are well served by `3600` plus a refresh before ICE restarts. ## Refreshing credentials - **New connection:** request new credentials for every new `RTCPeerConnection`. Requests are cheap and each one gets its own username. - **ICE restart:** if `expiresAt` has passed or is close, fetch new credentials first and update the connection: ```javascript async function restartIceWithFreshCredentials(pc) { const { iceServers } = await fetch("/api/turn-credentials").then((r) => r.json()); pc.setConfiguration({ ...pc.getConfiguration(), iceServers }); pc.restartIce(); } ``` - **Do not** cache credentials in `localStorage` or reuse them across users. ## Security checklist - Request credentials from your backend only. The [API token](https://turnix.io/docs/api-reference/authentication) must never reach a browser or app. - Protect your credentials endpoint with your app's own authentication, so strangers cannot use it to get free TURN relay. - Prefer short TTLs and refresh instead of long-lived credentials. - If software you do not control needs TURN access (Nextcloud Talk, Jitsi), use [shared-secret credentials](https://turnix.io/docs/integrations/shared-secret) instead of pasting a long-lived credential into its config. --- Source: https://turnix.io/docs/concepts/regions # Regions & geo-routing Turnix runs TURN servers in several regions. Each credential points at exactly one region, through the host `.turnix.io`. Relaying through a region close to your users keeps latency low. ## How Turnix picks the region For every [`POST /credentials/ice`](https://turnix.io/docs/api-reference/credentials) request, Turnix decides in this order: 1. **`fixed_region`**: use exactly this region. If the slug does not exist, the request fails with `422 {"error":"Specified Region Not Found"}`. If the region is offline or in maintenance, it fails with `422 {"error":"Region Offline"}`. 2. **`preferred_region`**: use this region if it exists and is online. Otherwise, silently fall through to step 3. 3. **GeoIP**: look up the location of the client IP (see below) and use the nearest region that is online. 4. **Fallback**: if the IP cannot be located, use the platform's default region. Use `fixed_region` when you must keep traffic in one place, for example for data residency. Use `preferred_region` when you have a good default but would rather get a working credential than an error. ## Which IP is used for GeoIP Turnix determines the client IP from the request, in this order: 1. the `X-TURN-CLIENT-IP` header 2. the first valid IP in the `X-Forwarded-For` header 3. the IP address of the connection > **Warning: Forward the end user's IP** > > You call the API from your backend, so without a header Turnix sees your **server's** IP and picks the region closest to your data center, not to your user. Send the user's public IP in `X-TURN-CLIENT-IP`: > > ```bash > curl -X POST https://turnix.io/api/v1/credentials/ice \ > -H "Authorization: Bearer $TURNIX_API_TOKEN" \ > -H "X-TURN-CLIENT-IP: 203.0.113.42" > ``` > > If your backend runs behind a load balancer, read the user's IP from your framework's trusted-proxy setting (for example `req.ip` in Express with `trust proxy`, or `request.remote_ip` in Rails), not from a raw header the user could forge. ## Listing regions [`GET /regions`](https://turnix.io/docs/api-reference/regions) returns all public regions with their slug, location and status. The dashboard shows the same list, with each region's TURN hostname, under [Server](https://turnix.io/app/turn/server). A region is usable when both `is_online` and `is_operational` are `true`. ## Regions and plans On the **free plan**, credentials are issued only in regions marked `is_trial: true`. Requests for other regions behave as follows: - `fixed_region` set to a non-trial region: `422 {"error":"Specified Region Not Found"}` - `preferred_region` set to a non-trial region: ignored, GeoIP picks the nearest trial region Paid plans can use every online region. See [Usage, limits & billing](https://turnix.io/docs/concepts/usage-limits). ## Region hostnames and ports Every region serves the same set of transports on its host: | URL | Transport | Use | |-----|-----------|-----| | `turn:.turnix.io:3478?transport=udp` | UDP | Fastest; the default choice | | `turn:.turnix.io:3478?transport=tcp` | TCP | Networks that block UDP | | `turns:.turnix.io:443?transport=tcp` | TLS over TCP on port 443 | Strict firewalls and proxies that allow only HTTPS-like traffic | | `turns:.turnix.io:443?transport=udp` | DTLS on port 443 | Included for completeness; most browsers ignore it | STUN is served globally at `stun:stun.turnix.io:3478`. Always pass all URLs from the response to `RTCPeerConnection`. The browser tries them in parallel and uses the best one that works. --- Source: https://turnix.io/docs/concepts/clients-rooms # Clients & rooms Clients and rooms are optional labels you attach to a credential request. They do not change how TURN works; they let you find a call, a participant or a session later in the dashboard and connect it to your own records. ## The three fields | Field | Meaning | |-------|---------| | `initiator_client` | The participant that starts the connection, usually the one that creates the SDP offer | | `receiver_client` | The participant that answers | | `room` | The call, meeting or channel the connection belongs to | Send any combination of them in the body of [`POST /credentials/ice`](https://turnix.io/docs/api-reference/credentials): **cURL** ```bash curl -X POST https://turnix.io/api/v1/credentials/ice \ -H "Authorization: Bearer $TURNIX_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ttl": 3600, "room": "standup-2026-10-30", "initiator_client": "user-1842", "receiver_client": "user-77"}' ``` **Node.js** ```javascript const response = await fetch("https://turnix.io/api/v1/credentials/ice", { method: "POST", headers: { Authorization: `Bearer ${process.env.TURNIX_API_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ ttl: 3600, room: "standup-2026-10-30", initiator_client: "user-1842", receiver_client: "user-77", }), }); ``` **Python** ```python response = requests.post( "https://turnix.io/api/v1/credentials/ice", headers={"Authorization": f"Bearer {os.environ['TURNIX_API_TOKEN']}"}, json={ "ttl": 3600, "room": "standup-2026-10-30", "initiator_client": "user-1842", "receiver_client": "user-77", }, timeout=10, ) ``` ## How Turnix stores them - Clients and rooms belong to a project and are identified by name. - The first request with a new name creates the client or room. Later requests with the same name reuse it. There is no separate "create" call. - When a request contains a `room`, the clients in that request are linked to the room. A room can hold any number of clients, and a client can be in many rooms. - The credential remembers its initiator, receiver and room, so every TURN session started with it can be traced back. In the dashboard you can then open: - [Rooms](https://turnix.io/app/turn/rooms): each room with its clients - [Clients](https://turnix.io/app/turn/clients): each client with its rooms, credentials and relayed sessions - [Credentials](https://turnix.io/app/turn/credentials): filter credentials by client - [Sessions](https://turnix.io/app/turn/sessions): TURN sessions and their traffic ## Naming rules Values may contain only letters, digits, `-` and `_` (`^[A-Za-z0-9_-]+$`). Any other character, including spaces, dots, `@` and an empty string, makes the **whole request** fail: ```json { "error": "room contains invalid characters", "code": "unprocessable_entity" } ``` So omit a field instead of sending it empty, and sanitize values before you send them, for example: ```javascript const label = (id) => String(id).replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 64) || undefined; ``` Keep names short and stable. Very long values are not useful in the dashboard. > **Warning: Do not send personal data** > > Everyone with access to your Turnix project can see these labels. Use internal IDs (`user-1842`) or opaque hashes, not email addresses or names. ## Patterns **1:1 calls.** Each side requests its own credentials. Send the same `room` and the same `initiator_client`/`receiver_client` pair from both sides, so both credentials appear under one room with both participants. **Group calls (SFU or mesh).** Send `room` and set `initiator_client` to the participant who is requesting. Leave `receiver_client` out. The room then lists everyone who joined. **Correlating with your system.** Use the same identifiers you use in your own logs (call ID, user ID). When a user reports a bad call, you can search for the room or client in the dashboard and see which region and sessions were involved. --- Source: https://turnix.io/docs/concepts/usage-limits # Usage, limits & billing Turnix plans are based on the TURN traffic your organization relays per billing period. This page explains how usage is counted and what happens at the limit. ## How usage is counted - Usage is measured in **GB of relayed TURN traffic**. - It is counted per **organization**, across all of its projects, for the current **billing period** (monthly, starting on your subscription date). - Each plan includes a traffic allowance. Current plans and prices are on the [pricing page](https://turnix.io/#pricing). You can see usage in the dashboard under [Billing](https://turnix.io/app/organization/billing) and [Stats](https://turnix.io/app/turn/statistics), or query it with the [Usage API](https://turnix.io/docs/api-reference/usage). ## What happens at the limit It depends on whether your plan allows overage: | Plan type | When usage reaches the included traffic | |-----------|------------------------------------------| | **Free plan** (no overage) | New credential requests fail with `429`. The limit is checked each time credentials are requested. | | **Paid plans** (overage available) | Credentials keep working. Traffic above the allowance is billed as overage at your plan's per-GB rate. | The `429` response looks like this: ```json { "error": "Usage limit exceeded", "message": "You have reached your data usage limit", "limit": "15 GB", "used": "15.02 GB" } ``` To recover, upgrade your plan in [Billing](https://turnix.io/app/organization/billing), or wait for the next billing period. > **Tip: Handle 429 gracefully** > > A `429` from Turnix is not a rate limit, so retrying does not help. Log it, alert yourself, and let calls fall back to STUN-only (direct connections still work for many users) instead of failing hard. ## Rate limits The API currently does not enforce a request rate limit. Request credentials once per connection, not in a polling loop. ## Monitoring usage from code [`GET /usage/current`](https://turnix.io/docs/api-reference/usage#get-usage-current) returns the current plan, period and usage. A simple check you can run from a cron job: ```bash curl -s https://turnix.io/api/v1/usage/current \ -H "Authorization: Bearer $TURNIX_API_TOKEN" # {"current_plan":"FREE","current_period_start":"...","current_period_end":"...", # "current_period_usage_gb":12.4,"is_overage_available":false,"limit_before_avarage_gb":15} ``` Alert when `current_period_usage_gb` approaches `limit_before_avarage_gb` and `is_overage_available` is `false`. --- Source: https://turnix.io/docs/sdks/javascript # JavaScript / Node.js `turnix-js` is a small, dependency-free wrapper around [`POST /credentials/ice`](https://turnix.io/docs/api-reference/credentials). It is meant for **server-side** JavaScript: Node.js 18 or newer, Deno, Bun, or edge runtimes with `fetch`. Package: [turnix-js on npm](https://www.npmjs.com/package/turnix-js) · Source: [github.com/turnixio/turnix-js](https://github.com/turnixio/turnix-js) > **Warning: Server-side only** > > The SDK needs your API token. Do not bundle it into browser code. Call it from your backend and send only the resulting `iceServers` to the client. **AI prompt: Integrate Turnix with turnix-js** ```text Add Turnix (managed TURN/STUN servers) to the WebRTC app in this repository, using the turnix-js SDK on the Node.js backend. Context about turnix-js and Turnix: - Install: npm install turnix-js (ES module, Node.js 18+): import { TurnixIO } from "turnix-js". - const turnix = new TurnixIO({ bearerToken: process.env.TURNIX_API_TOKEN }); const iceServers = await turnix.requestCredentials({ ttl, room, initiatorClient, receiverClient, preferredRegion, fixedRegion }) resolves to the iceServers array; on non-2xx it rejects with an Error containing the status and body. - The token is a secret project API token. Use it only on the server, never send it to the browser, never log or commit it. - room, initiatorClient and receiverClient may contain only letters, digits, "-" and "_". - Docs: https://turnix.io/docs/sdks/javascript and https://turnix.io/docs/api-reference/credentials Do the following: 1. Find the backend (Express, Fastify, Next.js route handlers, …) and where the browser creates RTCPeerConnection. Follow the existing conventions. If the backend is not JavaScript, stop and tell me to use the generic prompt at https://turnix.io/docs/getting-started/ai-agents instead. 2. Install turnix-js and create one TurnixIO instance at startup. Fail at startup with a clear message if TURNIX_API_TOKEN is missing. 3. Add an authenticated endpoint (for example GET /api/turn-credentials) that calls requestCredentials({ ttl: 3600, room, initiatorClient }) with sanitized call and user IDs, and returns { iceServers }. On errors, log the message without the token and return 502 (503 with "TURN usage limit reached" when the message contains 429). 4. In the browser, fetch the endpoint before creating each RTCPeerConnection and pass { iceServers }. Do not cache credentials in localStorage. 5. Add TURNIX_API_TOKEN to .env.example and the README, and a test that mocks TurnixIO and checks that the endpoint returns iceServers and requires login. 6. Summarize the changes and what I still need to do (setting TURNIX_API_TOKEN in production). ``` ## Install ```bash npm install turnix-js ``` The package is an ES module: ```javascript import { TurnixIO } from "turnix-js"; ``` ## Request credentials ```javascript import express from "express"; import { TurnixIO } from "turnix-js"; const turnix = new TurnixIO({ bearerToken: process.env.TURNIX_API_TOKEN }); const app = express(); app.get("/api/turn-credentials", requireLogin, async (req, res) => { try { const iceServers = await turnix.requestCredentials({ ttl: 3600, room: "room-42", initiatorClient: `user-${req.user.id}`, }); res.json({ iceServers }); } catch (err) { console.error(err.message); // e.g. "Failed to fetch ICE credentials: 429 {...}" res.status(502).json({ error: "Could not get TURN credentials" }); } }); ``` In the browser: ```javascript const { iceServers } = await fetch("/api/turn-credentials").then((r) => r.json()); const pc = new RTCPeerConnection({ iceServers }); ``` ## API ### `new TurnixIO(options)` | Option | Type | Default | Description | |--------|------|---------|-------------| | `bearerToken` | `string` | required | Your project API token. Throws `Error("Bearer token is required")` if missing. | | `apiUrl` | `string` | `https://turnix.io/api/v1/credentials/ice` | Full URL of the credentials endpoint. | ### `turnix.requestCredentials(options?)` Returns a `Promise` that resolves to the `iceServers` array. All options are optional and map to the [API parameters](https://turnix.io/docs/api-reference/credentials#parameters): | Option | Sent as | Description | |--------|---------|-------------| | `ttl` | `ttl` | Lifetime in seconds (whole number). | | `room` | `room` | [Room label](https://turnix.io/docs/concepts/clients-rooms). | | `initiatorClient` | `initiator_client` | Label of the participant starting the connection. | | `receiverClient` | `receiver_client` | Label of the answering participant. | | `preferredRegion` | `preferred_region` | Region slug to prefer; falls back to GeoIP if unavailable. | | `fixedRegion` | `fixed_region` | Region slug to require; the call fails if unavailable. | On a non-2xx response the promise rejects with an `Error` whose message contains the HTTP status and the response body, for example `Failed to fetch ICE credentials: 422 {"error":"Region Offline"}`. ## Limitations The current version (0.1.4) has a few gaps. If you need any of these, call the [HTTP API](https://turnix.io/docs/api-reference/credentials) directly; it is a single `fetch` call. - **No `expiresAt`.** Only `iceServers` is returned. Track expiry yourself (`Date.now() + ttl * 1000`). - **No client IP forwarding.** The SDK cannot set `X-TURN-CLIENT-IP`, so the region is chosen from your server's location unless you pass `preferredRegion` or `fixedRegion`. The README mentions a `clientIp` option, but it is not implemented in 0.1.4. - **`ttl: 0` is not sent** (falsy values are skipped), so the platform default applies. Equivalent direct call with client IP forwarding: ```javascript const res = await fetch("https://turnix.io/api/v1/credentials/ice", { method: "POST", headers: { Authorization: `Bearer ${process.env.TURNIX_API_TOKEN}`, "Content-Type": "application/json", "X-TURN-CLIENT-IP": req.ip, }, body: JSON.stringify({ ttl: 3600 }), }); if (!res.ok) throw new Error(`Turnix ${res.status}: ${await res.text()}`); const { iceServers, expiresAt } = await res.json(); ``` ## Demo [turnix-js-demo](https://github.com/turnixio/turnix-js-demo) is a minimal Express + WebSocket signaling app that uses the SDK for a two-party video call. --- Source: https://turnix.io/docs/sdks/dart # Dart / Flutter `turnix_dart` wraps [`POST /credentials/ice`](https://turnix.io/docs/api-reference/credentials) and [`GET /regions`](https://turnix.io/docs/api-reference/regions) for Dart. It returns typed objects, including the expiry time. Package: [turnix_dart on pub.dev](https://pub.dev/packages/turnix_dart) · Source: [github.com/turnixio/turnix_dart](https://github.com/turnixio/turnix_dart) > **Tip: Integrate in seconds with your AI agent** > > Building a Flutter app? The [Flutter integration guide](https://turnix.io/docs/integrations/flutter) has a ready-made prompt for Claude Code, Cursor or Copilot that adds the backend endpoint and the app side in one go. > **Warning: Use it on your server, not in the app** > > The SDK needs your API token. If you ship the token inside a Flutter app, anyone can extract it. Run the SDK in a Dart backend (for example with `shelf` or `dart_frog`), and let the app fetch only `iceServers` from that backend. ## Install ```bash dart pub add turnix_dart ``` ```dart import 'package:turnix_dart/turnix_dart.dart'; ``` ## Request credentials ```dart import 'dart:io'; import 'package:turnix_dart/turnix_dart.dart'; final creds = await TurnixIO.getIceCredentials( apiToken: Platform.environment['TURNIX_API_TOKEN']!, ttl: 3600, room: 'room-42', initiatorClient: 'user-1842', clientIp: '203.0.113.42', // the end user's public IP, for geo-routing ); print(creds.expiresAt); // DateTime (UTC) ``` Return the servers to your app as JSON, for example: ```dart final body = { '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(), }; ``` In the Flutter app, with [`flutter_webrtc`](https://pub.dev/packages/flutter_webrtc): ```dart final pc = await createPeerConnection({'iceServers': json['iceServers']}); ``` For the complete app side (fetching, refreshing before ICE restarts, verifying the relay), see the [Flutter integration guide](https://turnix.io/docs/integrations/flutter). ## API ### `TurnixIO.getIceCredentials(...)` Returns `Future`. Only `apiToken` is required. | Parameter | Type | Sent as | |-----------|------|---------| | `apiToken` | `String` | `Authorization: Bearer …` header | | `ttl` | `int?` | `ttl` | | `room` | `String?` | `room` | | `initiatorClient` | `String?` | `initiator_client` | | `receiverClient` | `String?` | `receiver_client` | | `preferredRegion` | `String?` | `preferred_region` | | `fixedRegion` | `String?` | `fixed_region` | | `clientIp` | `String?` | `X-TURN-CLIENT-IP` header | `IceCredentials` has `iceServers` (`List`, each with `urls`, `username?`, `credential?`) and `expiresAt` (`DateTime`). Throws `http.ClientException` with the status code (for example `Failed to fetch ICE credentials (429)`) for any non-200 response. ### `TurnixIO.getAvailableRegions(apiToken: ...)` Returns `Future>` with `slug`, `nat2` (country code), `city`, `name`, `lat`, `lon`, `isOnline` and `isOperational`. ```dart final regions = await TurnixIO.getAvailableRegions(apiToken: token); final usable = regions.where((r) => r.isOnline && r.isOperational); ``` ## Refreshing before expiry ```dart if (creds.expiresAt.difference(DateTime.now().toUtc()) < const Duration(seconds: 60)) { creds = await TurnixIO.getIceCredentials(apiToken: token, ttl: 3600); } ``` See [Credentials & TTL](https://turnix.io/docs/concepts/credentials) for when to refresh. --- Source: https://turnix.io/docs/integrations/shared-secret # Shared-secret credentials (LTC) Some software cannot call an API for TURN credentials. Instead, you configure a TURN host and a **shared secret**, and the software generates time-limited credentials itself. This is the widely used "TURN REST API" scheme from coturn (`use-auth-secret`). Turnix supports it with a per-project **auth secret**, shown in the dashboard as **LTC/Auth Token**. Use it for [Nextcloud Talk](https://turnix.io/docs/integrations/nextcloud), [Jitsi Meet](https://turnix.io/docs/integrations/jitsi), and any other app that asks for a "TURN secret". For code you write yourself, prefer the [credentials API](https://turnix.io/docs/api-reference/credentials): it adds geo-routing and client/room tracking. ## Get your auth secret 1. Open [LTC/Auth Token](https://turnix.io/app/turn/ltc) in the dashboard (under **Turn Server**) for the project you want to bill. 2. Click **Create**. The secret is shown on the page. 3. Store it like a password. > **Warning: Rotating replaces the secret immediately** > > **Rotate** creates a new secret and invalidates the old one at once. Every app configured with the old secret stops getting relay until you update it. Each project has one active secret. ## Pick a TURN host Shared-secret credentials do not use geo-routing: you choose the region yourself. Open [Server](https://turnix.io/app/turn/server) in the dashboard (or call [`GET /regions`](https://turnix.io/docs/api-reference/regions)) and pick an online region close to your users. On the free plan, use a region marked as available for your plan. | Setting | Value | |---------|-------| | STUN | `stun.turnix.io:3478` | | TURN host | `.turnix.io`, for example `us-west.turnix.io` | | TURN ports | `3478` for `turn:` over UDP and TCP, `443` for `turns:` (TLS over TCP) | | Secret | Your auth secret | | Realm | Not required | ## How the credentials are built The app generates them on its own, so you usually do not need to do anything. For reference, or if you build your own integration: - `username` = the expiry time as a Unix timestamp in seconds, for example `1793365200` - `credential` = Base64 of HMAC-SHA1 of the username, keyed with the auth secret ```javascript import crypto from "node:crypto"; const ttl = 3600; const username = String(Math.floor(Date.now() / 1000) + ttl); const credential = crypto .createHmac("sha1", process.env.TURNIX_AUTH_SECRET) .update(username) .digest("base64"); const iceServers = [ { urls: "stun:stun.turnix.io:3478" }, { urls: [ "turn:us-west.turnix.io:3478?transport=udp", "turn:us-west.turnix.io:3478?transport=tcp", "turns:us-west.turnix.io:443?transport=tcp", ], username, credential, }, ]; ``` The TURN server rejects credentials whose timestamp is in the past. Traffic relayed with them counts toward the project that owns the secret. ## Manage the secret via the API You can read and rotate the secret with a [project API token](https://turnix.io/docs/api-reference/authentication), for example from a deployment script: | Endpoint | Description | |----------|-------------| | `GET /api/v1/ltc/key` | Returns `{"key": ""}`, or `{}` if the project has no secret yet. | | `POST /api/v1/ltc/rotate` | Creates a new secret, invalidates the previous one, and returns `{"key": ""}`. | ```bash curl -s https://turnix.io/api/v1/ltc/key -H "Authorization: Bearer $TURNIX_API_TOKEN" ``` --- Source: https://turnix.io/docs/integrations/nextcloud # Nextcloud Talk [Nextcloud Talk](https://nextcloud.com/talk/) needs a TURN server so that participants behind firewalls and strict NATs can join calls. Talk generates TURN credentials itself from a shared secret, so you connect it to Turnix with your project's [auth secret](https://turnix.io/docs/integrations/shared-secret). This takes about five minutes. > **Tip: Let your AI agent configure it** > > Run Claude Code (or another agent with shell access) on your Nextcloud server, set `TURNIX_AUTH_SECRET` in that shell, and paste the prompt below. The agent configures Talk with `occ` and checks the result. **AI prompt: Configure Nextcloud Talk with Turnix** ```text Configure Nextcloud Talk on this server to use Turnix (managed TURN/STUN) as its STUN and TURN server. Context about Turnix: - STUN server: stun.turnix.io:3478 - TURN servers: .turnix.io on port 3478 (turn, UDP and TCP) and port 443 (turns, TCP only). Ask me which region to use (for example us-west) if I have not said so. The region list is at https://turnix.io/app/turn/server. - Talk generates TURN credentials itself from a shared secret. The secret is my Turnix project's auth secret, available in the environment variable TURNIX_AUTH_SECRET. Never print it, write it into files other than the Nextcloud config, or include it in your summary. - Docs: https://turnix.io/docs/integrations/nextcloud Do the following: 1. Find the Nextcloud installation and how occ is run here (usually sudo -u www-data php occ in the Nextcloud directory, or docker exec into the app container). Check that the Talk app (spreed) is installed and enabled. If TURNIX_AUTH_SECRET is empty, stop and ask me to set it. 2. Show me the current settings with occ talk:stun:list and occ talk:turn:list before changing anything. 3. Add the STUN server: occ talk:stun:add stun.turnix.io:3478. Remove the default stun.nextcloud.com:443 entry if present (occ talk:stun:delete). 4. Add the TURN servers: occ talk:turn:add turn .turnix.io:3478 udp,tcp --secret="$TURNIX_AUTH_SECRET" and occ talk:turn:add turns .turnix.io:443 tcp --secret="$TURNIX_AUTH_SECRET". Remove old TURN entries that point to a local coturn only after asking me. 5. Run occ talk:stun:list and occ talk:turn:list again and show me the result with the secret masked. 6. Tell me how to verify: start a call between a laptop on Wi-Fi and a phone on mobile data, then check Sessions in the Turnix dashboard (https://turnix.io/app/turn/sessions). ``` ## Before you start - Admin access to your Nextcloud instance with the Talk app installed - A Turnix project (the free plan works) ## 1. Get the Turnix values 1. In the Turnix dashboard, open [LTC/Auth Token](https://turnix.io/app/turn/ltc) and click **Create** (or copy the existing secret). ![Auth secret in the Turnix dashboard](https://turnix.io/docs-assets/auth_token_ltc.png) 2. Open [Server](https://turnix.io/app/turn/server) and copy the endpoint of an online region close to your users, for example `us-west.turnix.io`. ![Region endpoints in the Turnix dashboard](https://turnix.io/docs-assets/auth_region.png) ## 2. Configure Talk in the admin UI In Nextcloud, go to **Administration settings → Talk**. **STUN servers:** replace the default entry with: ```text stun.turnix.io:3478 ``` **TURN servers:** add two entries, one for plain TURN and one for TURN over TLS on port 443: | Field | Entry 1 | Entry 2 | |-------|---------|---------| | Schemes | `turn: only` | `turns: only` | | TURN server URL | `us-west.turnix.io:3478` | `us-west.turnix.io:443` | | TURN server secret | your auth secret | your auth secret | | Protocols | `UDP and TCP` | `TCP only` | Enter the URL without a `turn:` prefix. Use the **Test this server** button next to an entry to check it; a successful test shows a checkmark. ![Nextcloud Talk TURN settings with Turnix](https://turnix.io/docs-assets/auth_nextcloud.png) ## Alternative: configure with occ On recent Talk versions you can configure the same settings from the command line (run as the web server user, for example `sudo -u www-data php occ …`): ```bash php occ talk:stun:add stun.turnix.io:3478 php occ talk:turn:add turn us-west.turnix.io:3478 udp,tcp --secret="$TURNIX_AUTH_SECRET" php occ talk:turn:add turns us-west.turnix.io:443 tcp --secret="$TURNIX_AUTH_SECRET" php occ talk:turn:list ``` Remove the default STUN server if you no longer want it: `php occ talk:stun:list` and `php occ talk:stun:delete `. ## 3. Verify 1. Start a call between two devices on different networks, for example a laptop on office Wi-Fi and a phone on mobile data. 2. In the Turnix dashboard, open [Sessions](https://turnix.io/app/turn/sessions). Relayed calls appear there and their traffic counts toward the project that owns the secret. ## Troubleshooting | Problem | Fix | |---------|-----| | Test fails or calls do not connect behind firewalls | Check that the secret matches the dashboard exactly and that the region is online. If you rotated the secret, update it in Talk. | | Works on some networks only | Make sure the `turns:` entry on port `443` with `TCP only` is configured. | | Calls with more than a few participants are poor | TURN solves connectivity, not scaling. For larger calls, Nextcloud recommends its High-performance backend. | --- Source: https://turnix.io/docs/integrations/jitsi # Jitsi Meet A self-hosted [Jitsi Meet](https://jitsi.org/jitsi-meet/) needs a TURN server for participants on networks that block UDP or use strict NATs. Without it, those users see frozen video, one-way audio, or cannot join. There are two ways to connect Jitsi to Turnix: | | Option A: shared secret (recommended) | Option B: static credentials | |---|---|---| | How | Prosody generates fresh TURN credentials per user from your [auth secret](https://turnix.io/docs/integrations/shared-secret) | You paste one credential from the dashboard into the config | | Expiry | Handled automatically | The credential stops working at its TTL; you must replace it | | Use for | Production | A quick test | > **Tip: Let your AI agent configure it** > > Run Claude Code (or another agent with shell access) on your Jitsi server, set `TURNIX_AUTH_SECRET` in that shell, and paste the prompt below. The agent configures Prosody (Option A), checks the config and restarts the services. **AI prompt: Configure Jitsi Meet with Turnix** ```text Configure the self-hosted Jitsi Meet on this server to use Turnix (managed TURN/STUN) through Prosody's mod_external_services with a shared secret. Context about Turnix: - STUN server: stun.turnix.io port 3478 - TURN servers: .turnix.io, port 3478 (turn, UDP and TCP) and port 443 (turns, TCP). Ask me which region to use (for example us-west) if I have not said so. The region list is at https://turnix.io/app/turn/server. - The shared secret is my Turnix project's auth secret, available in the environment variable TURNIX_AUTH_SECRET. Never print it or include it in your summary. - Docs: https://turnix.io/docs/integrations/jitsi Do the following: 1. Find the Prosody config for the Jitsi domain (usually /etc/prosody/conf.avail/.cfg.lua) and make a timestamped backup copy. If TURNIX_AUTH_SECRET is empty, stop and ask me to set it. 2. Check that "external_services" is in modules_enabled of the main VirtualHost; add it if missing. 3. At the top level of the file, replace any existing external_service_secret and external_services settings with: external_service_secret = ""; external_services = { { type = "stun", host = "stun.turnix.io", port = 3478 }, { type = "turn", host = ".turnix.io", port = 3478, transport = "udp", secret = true, ttl = 86400, algorithm = "turn" }, { type = "turn", host = ".turnix.io", port = 3478, transport = "tcp", secret = true, ttl = 86400, algorithm = "turn" }, { type = "turns", host = ".turnix.io", port = 443, transport = "tcp", secret = true, ttl = 86400, algorithm = "turn" } }; Show me the diff with the secret masked. 4. Run prosodyctl check config and fix any error you introduced. Then restart: systemctl restart prosody jicofo jitsi-videobridge2. 5. If a local coturn is installed and no longer referenced, tell me, but do not stop or remove it without asking. 6. Tell me how to verify: join a meeting from a network that blocks UDP and look for relay candidates on .turnix.io in chrome://webrtc-internals, or check Sessions in the Turnix dashboard (https://turnix.io/app/turn/sessions). ``` ## Before you start - A working self-hosted Jitsi Meet (for example the Debian/Ubuntu packages from the [Jitsi handbook](https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-quickstart)), with shell access to the server - A Turnix project (the free plan works) ## Option A: shared secret via Prosody (recommended) Jitsi clients get their STUN/TURN servers from Prosody's `mod_external_services`. Point it at Turnix. ### 1. Get the Turnix values 1. Open [LTC/Auth Token](https://turnix.io/app/turn/ltc) in the dashboard and click **Create** (or copy the existing secret). 2. Open [Server](https://turnix.io/app/turn/server) and copy the endpoint of an online region close to your users, for example `us-west.turnix.io`. ### 2. Configure Prosody Edit your Prosody config, usually `/etc/prosody/conf.avail/.cfg.lua`. Make sure `external_services` is listed in `modules_enabled` of your main `VirtualHost` (the Debian packages enable it by default). Then replace any existing `external_service_secret` and `external_services` settings at the top level of the file with: ```lua external_service_secret = "YOUR_TURNIX_AUTH_SECRET"; external_services = { { type = "stun", host = "stun.turnix.io", port = 3478 }, { type = "turn", host = "us-west.turnix.io", port = 3478, transport = "udp", secret = true, ttl = 86400, algorithm = "turn" }, { type = "turn", host = "us-west.turnix.io", port = 3478, transport = "tcp", secret = true, ttl = 86400, algorithm = "turn" }, { type = "turns", host = "us-west.turnix.io", port = 443, transport = "tcp", secret = true, ttl = 86400, algorithm = "turn" } }; ``` `ttl` is how long each generated credential is valid, in seconds. Prosody generates a new one every time a participant joins. If your installation still runs a local coturn that you no longer need, remove its entries from `external_services` and stop the service. ### 3. Restart and verify ```bash sudo systemctl restart prosody jicofo jitsi-videobridge2 ``` Join a meeting from a restrictive network (for example a corporate VPN or a guest Wi-Fi that blocks UDP). In `chrome://webrtc-internals` you should see `relay` candidates on `us-west.turnix.io`, and the session appears under [Sessions](https://turnix.io/app/turn/sessions) in the Turnix dashboard. ## Option B: static credentials (quick test) 1. In the Turnix dashboard, open [Credentials](https://turnix.io/app/turn/credentials) and create a new credential. Choose a region and a TTL (for a test, a few hours is enough). 2. In the result, switch to the **JitsiMeet** tab and copy the JSON array. 3. Paste it as the STUN/TURN server list in your Jitsi Meet `config.js` (usually `/etc/jitsi/meet/-config.js`), under `p2p.stunServers`: ```javascript p2p: { enabled: true, stunServers: [ { urls: "stun:stun.turnix.io:3478" }, { urls: "turn:us-west.turnix.io:3478?transport=udp", username: "", credential: "" }, { urls: "turn:us-west.turnix.io:3478?transport=tcp", username: "", credential: "" }, { urls: "turns:us-west.turnix.io:443?transport=tcp", username: "", credential: "" } ] }, ``` > **Warning: Static credentials expire** > > The credential stops working at the TTL you chose, and anyone who opens your meeting page can read it from `config.js`. Use Option A for production. `p2p.stunServers` applies to one-to-one calls in peer-to-peer mode. Calls through the videobridge get their TURN servers from Prosody, so Option A covers both. ## Troubleshooting | Problem | Fix | |---------|-----| | No `relay` candidates | Check that the secret matches the dashboard and that `mod_external_services` is enabled. Run `prosodyctl check config`. | | Worked, then stopped | You rotated the auth secret (update Prosody), or the static credential from Option B expired. | | Only users on restrictive networks fail | Make sure the `turns` entry on port `443` over TCP is present. | --- Source: https://turnix.io/docs/integrations/flutter # 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 ". 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":"","credential":"","urls":["turn:.turnix.io:3478?transport=udp","turn:.turnix.io:3478?transport=tcp","turns:.turnix.io:443?transport=udp","turns:.turnix.io:443?transport=tcp"]}],"expiresAt":""} - 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 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> iceServers; final DateTime expiresAt; bool get expiresSoon => expiresAt.difference(DateTime.now().toUtc()) < const Duration(seconds: 60); } Future 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; return TurnCredentials( (json['iceServers'] as List).cast>(), 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 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 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) --- Source: https://turnix.io/docs/api-reference # API overview The Turnix API is a small JSON API. Most integrations only need one endpoint: [`POST /credentials/ice`](https://turnix.io/docs/api-reference/credentials). ## Base URL ```text https://turnix.io/api/v1 ``` The version is part of the path. `GET /api/v1` returns `{"api_version": 1}`. ## Endpoints | Method | Path | Description | |--------|------|-------------| | `POST` | [`/credentials/ice`](https://turnix.io/docs/api-reference/credentials) | Create ICE credentials (STUN + TURN) | | `GET` | [`/regions`](https://turnix.io/docs/api-reference/regions) | List TURN regions | | `GET` | [`/usage/current`](https://turnix.io/docs/api-reference/usage#get-usage-current) | Current plan, billing period and traffic | | `GET` | [`/usage/list_periods`](https://turnix.io/docs/api-reference/usage#get-usage-list_periods) | Traffic for all billing periods | | `GET` | [`/ltc/key`](https://turnix.io/docs/integrations/shared-secret#manage-the-secret-via-the-api) | Read the project's auth secret | | `POST` | [`/ltc/rotate`](https://turnix.io/docs/integrations/shared-secret#manage-the-secret-via-the-api) | Rotate the project's auth secret | ## Authentication Send a project API token as a Bearer token on every request: ```http Authorization: Bearer YOUR_API_TOKEN ``` The token identifies your organization and project, so you never pass their IDs. See [Authentication](https://turnix.io/docs/api-reference/authentication). ## Requests and responses - Request bodies can be JSON (`Content-Type: application/json`) or form-encoded (`application/x-www-form-urlencoded`). All body parameters are optional. - Responses are JSON. Timestamps are ISO 8601 in UTC, for example `2026-10-30T13:00:00.000Z`. - Use HTTPS. Call the API from your backend only. ## Errors Errors return a non-2xx status and a JSON body with an `error` message. Some errors add a `code` or extra fields: ```json { "error": "Token not found", "code": "unauthorized" } ``` | Status | Meaning | Examples | |--------|---------|----------| | `400` | A required parameter is missing | `{"error":"Required parameter missing: …"}` | | `401` | Not authenticated, or the organization has no subscription | `Not Authorized`, `Token not found`, `You are not on any subscription` | | `422` | A parameter is invalid or the request cannot be fulfilled | `TTL must be an integer`, `room contains invalid characters`, `Specified Region Not Found`, `Region Offline` | | `429` | Your organization reached its data usage limit | `Usage limit exceeded` (see [Usage, limits & billing](https://turnix.io/docs/concepts/usage-limits)) | | `5xx` | Something went wrong on our side | Retry with backoff | Match on the HTTP status, not on the exact message text. ## Limits - **Usage limit:** on plans without overage, `POST /credentials/ice` returns `429` once the organization's traffic for the billing period reaches the plan's allowance. See [Usage, limits & billing](https://turnix.io/docs/concepts/usage-limits). - **Rate limit:** none is currently enforced. Request credentials once per connection. --- Source: https://turnix.io/docs/api-reference/authentication # Authentication The Turnix API uses **API tokens** sent as Bearer tokens: ```http Authorization: Bearer YOUR_API_TOKEN ``` ## API tokens - Each token belongs to one **project** in your organization. Credentials you request with it, and their traffic, are recorded in that project. - A token works for all API endpoints of its project. There are no finer-grained permissions or scopes to choose. - Tokens do not expire. They stay valid until you remove them. ## Create a token 1. Sign in to the [dashboard](https://turnix.io/app) and select the project. 2. Open [API Tokens](https://turnix.io/app/project/tokens) and click **Create new API Token**. 3. Enter a name (for example `production-backend`) and copy the token. > **Warning: The token is shown only once** > > After you close the dialog, the token cannot be displayed again. If you lose it, create a new one. ## Store and use it safely - Keep the token on your server, for example in an environment variable (`TURNIX_API_TOKEN`) or a secrets manager. - Never put it in browser JavaScript, mobile apps, desktop apps or public repositories. Anyone who has it can create credentials and relay traffic billed to you. - Do not log full `Authorization` headers. - Use separate tokens (and ideally separate projects) for staging and production, so you can replace one without touching the other. ## Rotate or remove a token To rotate, create a new token, deploy it, then **remove** the old one under [API Tokens](https://turnix.io/app/project/tokens). > **Danger: Use Remove, not Revoke, after a leak** > > To cut off a leaked token for certain, click **Remove**. Removing deletes the token, and every request that uses it fails with `401 Token not found`. ## Errors | Status | Body | Cause | |--------|------|-------| | `401` | `{"error":"Not Authorized","code":"unauthorized"}` | No `Authorization` header, or it does not use the `Bearer ` scheme | | `401` | `{"error":"Token not found","code":"unauthorized"}` | The token does not exist (typo, or it was removed) | | `401` | `{"error":"You are not on any subscription","code":401}` | The organization has no active plan | ## Example **cURL** ```bash curl https://turnix.io/api/v1/regions \ -H "Authorization: Bearer $TURNIX_API_TOKEN" ``` **Node.js** ```javascript const res = await fetch("https://turnix.io/api/v1/regions", { headers: { Authorization: `Bearer ${process.env.TURNIX_API_TOKEN}` }, }); ``` **Python** ```python import os, requests res = requests.get( "https://turnix.io/api/v1/regions", headers={"Authorization": f"Bearer {os.environ['TURNIX_API_TOKEN']}"}, timeout=10, ) ``` --- Source: https://turnix.io/docs/api-reference/credentials # Credentials ## `POST /credentials/ice` Creates a new TURN credential and returns it, with the STUN and TURN URLs, as an `iceServers` array you can pass straight to `RTCPeerConnection`. ```text POST https://turnix.io/api/v1/credentials/ice ``` Call this endpoint from your backend, once per new peer connection. See [Credentials & TTL](https://turnix.io/docs/concepts/credentials) for the lifecycle. ## Headers | Header | Required | Description | |--------|----------|-------------| | `Authorization` | Yes | `Bearer YOUR_API_TOKEN`. See [Authentication](https://turnix.io/docs/api-reference/authentication). | | `Content-Type` | With a body | `application/json` or `application/x-www-form-urlencoded` | | `X-TURN-CLIENT-IP` | Recommended | The end user's public IP address, used to choose the nearest region. Without it, Turnix uses `X-Forwarded-For` or the IP of your server. See [Regions & geo-routing](https://turnix.io/docs/concepts/regions#which-ip-is-used-for-geoip). | ## Parameters All parameters are optional and go in the request body. | Parameter | Type | Description | |-----------|------|-------------| | `ttl` | integer | Lifetime of the credential in seconds. Digits only. Default: `30`. No minimum or maximum is enforced. | | `preferred_region` | string | Region slug to use if it is online. If it is unknown, offline or not in your plan, Turnix falls back to GeoIP without an error. | | `fixed_region` | string | Region slug that must be used. Fails with `422` if it is unknown or offline. Takes precedence over `preferred_region`. | | `room` | string | [Room](https://turnix.io/docs/concepts/clients-rooms) label. Letters, digits, `-` and `_` only. | | `initiator_client` | string | Label of the participant that starts the connection. Same character rules. | | `receiver_client` | string | Label of the answering participant. Same character rules. | Get region slugs from [`GET /regions`](https://turnix.io/docs/api-reference/regions). ## Response `200 OK` ```json { "iceServers": [ { "urls": ["stun:stun.turnix.io:3478"] }, { "username": "3f1c2b9e-8f4e-4d7a-9a51-2f6a0c7d1e42", "credential": "9b2f4c1d7e8a6b3c5d0e1f2a3b4c5d6e", "urls": [ "turn:us-west.turnix.io:3478?transport=udp", "turn:us-west.turnix.io:3478?transport=tcp", "turns:us-west.turnix.io:443?transport=udp", "turns:us-west.turnix.io:443?transport=tcp" ] } ], "expiresAt": "2026-10-30T13:00:00.000Z" } ``` | Field | Description | |-------|-------------| | `iceServers` | Array in the [`RTCIceServer`](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/RTCPeerConnection#iceservers) format. Always two entries: STUN first, then TURN. | | `iceServers[1].username` | Unique TURN username (a UUID) for this credential | | `iceServers[1].credential` | TURN password for this credential | | `iceServers[1].urls` | TURN URLs on the selected region's host `.turnix.io`: UDP and TCP on port 3478, and TLS on port 443. See [hostnames and ports](https://turnix.io/docs/concepts/regions#region-hostnames-and-ports). | | `expiresAt` | When the credential stops being accepted for new relays (ISO 8601, UTC) | The selected region is the subdomain of the TURN URLs. ## Examples ### Basic request **cURL** ```bash curl -X POST https://turnix.io/api/v1/credentials/ice \ -H "Authorization: Bearer $TURNIX_API_TOKEN" \ -H "Content-Type: application/json" \ -H "X-TURN-CLIENT-IP: 203.0.113.42" \ -d '{"ttl": 3600}' ``` **Node.js** ```javascript const res = await fetch("https://turnix.io/api/v1/credentials/ice", { method: "POST", headers: { Authorization: `Bearer ${process.env.TURNIX_API_TOKEN}`, "Content-Type": "application/json", "X-TURN-CLIENT-IP": userIp, }, body: JSON.stringify({ ttl: 3600 }), }); if (!res.ok) throw new Error(`Turnix ${res.status}: ${await res.text()}`); const { iceServers, expiresAt } = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://turnix.io/api/v1/credentials/ice", headers={ "Authorization": f"Bearer {os.environ['TURNIX_API_TOKEN']}", "X-TURN-CLIENT-IP": user_ip, }, json={"ttl": 3600}, timeout=10, ) res.raise_for_status() data = res.json() ice_servers, expires_at = data["iceServers"], data["expiresAt"] ``` ### Choose a region **cURL** ```bash # Prefer us-west, fall back to the nearest region curl -X POST https://turnix.io/api/v1/credentials/ice \ -H "Authorization: Bearer $TURNIX_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ttl": 3600, "preferred_region": "us-west"}' # Require us-west, fail if it is not available curl -X POST https://turnix.io/api/v1/credentials/ice \ -H "Authorization: Bearer $TURNIX_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ttl": 3600, "fixed_region": "us-west"}' ``` **Node.js** ```javascript const res = await fetch("https://turnix.io/api/v1/credentials/ice", { method: "POST", headers: { Authorization: `Bearer ${process.env.TURNIX_API_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ ttl: 3600, preferred_region: "us-west" }), // or fixed_region }); ``` **Python** ```python res = requests.post( "https://turnix.io/api/v1/credentials/ice", headers={"Authorization": f"Bearer {os.environ['TURNIX_API_TOKEN']}"}, json={"ttl": 3600, "preferred_region": "us-west"}, # or fixed_region timeout=10, ) ``` ### Label clients and room **cURL** ```bash curl -X POST https://turnix.io/api/v1/credentials/ice \ -H "Authorization: Bearer $TURNIX_API_TOKEN" \ -d ttl=3600 \ -d room=standup-2026-10-30 \ -d initiator_client=user-1842 \ -d receiver_client=user-77 ``` **Node.js** ```javascript const res = await fetch("https://turnix.io/api/v1/credentials/ice", { method: "POST", headers: { Authorization: `Bearer ${process.env.TURNIX_API_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ ttl: 3600, room: "standup-2026-10-30", initiator_client: "user-1842", receiver_client: "user-77", }), }); ``` **Python** ```python res = requests.post( "https://turnix.io/api/v1/credentials/ice", headers={"Authorization": f"Bearer {os.environ['TURNIX_API_TOKEN']}"}, json={ "ttl": 3600, "room": "standup-2026-10-30", "initiator_client": "user-1842", "receiver_client": "user-77", }, timeout=10, ) ``` ## Region selection 1. `fixed_region` if given (error if unknown or offline) 2. `preferred_region` if given, known and online 3. The online region nearest to the client IP (`X-TURN-CLIENT-IP`, then `X-Forwarded-For`, then the connection IP) 4. The platform's default region if the IP cannot be located On the free plan, only regions marked `is_trial` are considered. Details: [Regions & geo-routing](https://turnix.io/docs/concepts/regions). ## Errors | Status | Body | Cause | |--------|------|-------| | `401` | `{"error":"Not Authorized","code":"unauthorized"}` | Missing `Authorization` header or not a Bearer token | | `401` | `{"error":"Token not found","code":"unauthorized"}` | Unknown or removed token | | `401` | `{"error":"You are not on any subscription","code":401}` | The organization has no active plan | | `422` | `{"error":"TTL must be an integer"}` | `ttl` is not a whole, non-negative number | | `422` | `{"error":"room contains invalid characters","code":"unprocessable_entity"}` | `room`, `initiator_client` or `receiver_client` is empty or contains characters other than letters, digits, `-`, `_` (the field name varies) | | `422` | `{"error":"Specified Region Not Found"}` | `fixed_region` is unknown, or not available on your plan | | `422` | `{"error":"Region Offline"}` | `fixed_region` is offline or in maintenance | | `422` | `{"error":"Error By limit calculation"}` | Usage could not be checked. Retry later; contact support if it persists. | | `429` | see below | The organization reached its data usage limit | `429 Too Many Requests` is returned when your plan has no overage and the traffic for the current billing period has reached the plan's allowance: ```json { "error": "Usage limit exceeded", "message": "You have reached your data usage limit", "limit": "15 GB", "used": "15.02 GB" } ``` Retrying does not help. Upgrade the plan or wait for the next period. See [Usage, limits & billing](https://turnix.io/docs/concepts/usage-limits). --- Source: https://turnix.io/docs/api-reference/regions # Regions ## `GET /regions` Lists the public TURN regions. Use a region's `slug` as `preferred_region` or `fixed_region` in [`POST /credentials/ice`](https://turnix.io/docs/api-reference/credentials), or to build a TURN hostname (`.turnix.io`) for [shared-secret integrations](https://turnix.io/docs/integrations/shared-secret). ```text GET https://turnix.io/api/v1/regions ``` ## Query parameters | Parameter | Description | |-----------|-------------| | `for_org` | Set to any value (for example `for_org=1`) to list only the regions your organization's plan can use. On the free plan, these are the regions with `is_trial: true`. | ## Request **cURL** ```bash curl https://turnix.io/api/v1/regions \ -H "Authorization: Bearer $TURNIX_API_TOKEN" ``` **Node.js** ```javascript const res = await fetch("https://turnix.io/api/v1/regions", { headers: { Authorization: `Bearer ${process.env.TURNIX_API_TOKEN}` }, }); const regions = await res.json(); const usable = regions.filter((r) => r.is_online && r.is_operational); ``` **Python** ```python regions = requests.get( "https://turnix.io/api/v1/regions", headers={"Authorization": f"Bearer {os.environ['TURNIX_API_TOKEN']}"}, timeout=10, ).json() usable = [r for r in regions if r["is_online"] and r["is_operational"]] ``` ## Response `200 OK`: a JSON array. ```json [ { "slug": "us-west", "nat2": "us", "city": "Portland", "name": "US West", "lat": 45.5152, "lon": -122.6784, "is_online": true, "is_operational": true, "is_trial": false }, { "slug": "singapore", "nat2": "sg", "city": "Singapore", "name": "Singapore", "lat": 1.28455, "lon": 103.84429, "is_online": false, "is_operational": false, "is_trial": false } ] ``` | Field | Type | Description | |-------|------|-------------| | `slug` | string | Region identifier. TURN host: `.turnix.io` | | `nat2` | string | ISO 3166-1 alpha-2 country code, lowercase | | `city` | string | City where the region is located | | `name` | string | Display name | | `lat`, `lon` | number | Coordinates, used for geo-routing | | `is_online` | boolean | The region's servers are reachable | | `is_operational` | boolean | The region accepts new credentials. Use regions where both flags are `true`. | | `is_trial` | boolean | The region is available on the free plan | The list of regions changes as we add capacity. Read it at runtime instead of hard-coding slugs, or check the [Server page](https://turnix.io/app/turn/server) in the dashboard. --- Source: https://turnix.io/docs/api-reference/usage # Usage Read your organization's plan and relayed traffic. Usage is counted per organization across all projects. See [Usage, limits & billing](https://turnix.io/docs/concepts/usage-limits) for how limits work. ## `GET /usage/current` Returns the current plan and the traffic in the current billing period. ```bash curl https://turnix.io/api/v1/usage/current \ -H "Authorization: Bearer $TURNIX_API_TOKEN" ``` `200 OK` ```json { "current_plan": "STARTUP", "current_period_start": "2026-10-18T20:32:04.000Z", "current_period_end": "2026-11-18T20:32:04.000Z", "current_period_usage_gb": 78.79, "is_overage_available": true, "limit_before_avarage_gb": 150 } ``` | Field | Type | Description | |-------|------|-------------| | `current_plan` | string | Plan name | | `current_period_start`, `current_period_end` | string | Current billing period (ISO 8601, UTC) | | `current_period_usage_gb` | number | Relayed traffic in this period, in GB | | `is_overage_available` | boolean | `true`: traffic above the allowance is billed as overage. `false`: credential requests return `429` once the allowance is used up. | | `limit_before_avarage_gb` | number | Traffic included in the plan, in GB. (The field name is spelled `avarage` in the API.) | Errors: `401 {"error":"You are not on any subscription","code":401}` if the organization has no active plan. ## `GET /usage/list_periods` Returns all billing periods of your organization, oldest first, with their traffic. ```bash curl https://turnix.io/api/v1/usage/list_periods \ -H "Authorization: Bearer $TURNIX_API_TOKEN" ``` `200 OK` ```json { "subscription_periods": [ { "subscription": { "name": "FREE" }, "current_period_start": "2026-08-18T20:32:04.000Z", "current_period_end": "2026-09-18T20:32:04.000Z", "usage": 4.22 }, { "subscription": { "name": "STARTUP" }, "current_period_start": "2026-09-18T20:32:04.000Z", "current_period_end": "2026-10-18T20:32:04.000Z", "usage": 55.76 } ] } ``` | Field | Description | |-------|-------------| | `subscription.name` | Plan during that period | | `current_period_start`, `current_period_end` | Period boundaries (ISO 8601, UTC) | | `usage` | Relayed traffic in that period, in GB |