WebRTC
TURN/STUN credentials for peer-to-peer video streaming

WebRTC API
Retrieve credentials for STUN/TURN servers to enable peer-to-peer video streaming.
Base URL:
http://preview.cliqer.io | WebSocket:wss://preview.cliqer.io/wsGet TURN Credentials
Retrieve temporary TURN server credentials for WebRTC connections.
Endpoint: GET /api/turn-credentials
Origin-gated, no session, This endpoint is protected by an Origin allow-list, not authentication. Browsers automatically send an allowed
Origin; server-to-server callers must set one (e.g. Origin: https://cliqer.io). Requests from a disallowed or missing origin receive 403 Forbidden.interface TurnCredentials {
// Ready-to-use ICE servers: a Cloudflare STUN entry plus Cloudflare-minted
// TURN entries (each with its own username/credential).
iceServers: RTCIceServer[]
ttl: number
}
const credentials: TurnCredentials = await $fetch('/api/turn-credentials')
import requests
BASE_URL = "$BASE_URL"
# No auth needed, but the request must carry an allowed Origin header.
response = requests.get(
f"$BASE_URL/api/turn-credentials",
headers={"Origin": "https://cliqer.io"}
)
credentials = response.json() # { "iceServers": [...], "ttl": 86400 }
const baseURL = "$BASE_URL"
// No auth needed, just send an allowed Origin header.
req, _ := http.NewRequest("GET", baseURL+"/api/turn-credentials", nil)
req.Header.Set("Origin", "https://cliqer.io")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var credentials struct {
IceServers []struct {
URLs any `json:"urls"` // string or []string
Username string `json:"username"`
Credential string `json:"credential"`
} `json:"iceServers"`
TTL int `json:"ttl"`
}
json.NewDecoder(resp.Body).Decode(&credentials)
use reqwest::Client;
use serde::Deserialize;
#[derive(Deserialize)]
struct IceServer {
urls: serde_json::Value, // string or array of strings
#[serde(default)]
username: Option<String>,
#[serde(default)]
credential: Option<String>,
}
#[derive(Deserialize)]
struct TurnCredentials {
#[serde(rename = "iceServers")]
ice_servers: Vec<IceServer>,
ttl: i32,
}
let client = Client::new();
const BASE_URL: &str = "$BASE_URL";
// No auth needed, just send an allowed Origin header.
let credentials: TurnCredentials = client
.get(format!("{}/api/turn-credentials", BASE_URL))
.header("Origin", "https://cliqer.io")
.send()
.await?
.json()
.await?;
Response:
{
"iceServers": [
{ "urls": "stun:stun.cloudflare.com:3478" },
{
"urls": [
"turn:turn.cloudflare.com:3478?transport=udp",
"turn:turn.cloudflare.com:3478?transport=tcp",
"turns:turn.cloudflare.com:5349?transport=tcp"
],
"username": "<cloudflare-generated>",
"credential": "<cloudflare-generated>"
}
],
"ttl": 86400
}
Using TURN Credentials
Browser (JavaScript)
const credentials = await $fetch('/api/turn-credentials')
// The response already contains ready-to-use ICE servers, pass them straight through.
const peerConnection = new RTCPeerConnection({
iceServers: credentials.iceServers
})
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'webrtcSignal',
payload: {
room: roomId,
signal: { type: 'candidate', candidate: event.candidate }
}
}))
}
}
# Python WebRTC using aiortc
from aiortc import RTCPeerConnection, RTCConfiguration, RTCIceServer
import requests
BASE_URL = "$BASE_URL"
# No auth, send an allowed Origin. Response is already { iceServers, ttl }.
credentials = requests.get(
f"$BASE_URL/api/turn-credentials",
headers={"Origin": "https://cliqer.io"}
).json()
config = RTCConfiguration(iceServers=[
RTCIceServer(
urls=s["urls"],
username=s.get("username"),
credential=s.get("credential")
)
for s in credentials["iceServers"]
])
pc = RTCPeerConnection(configuration=config)
// Go WebRTC using pion/webrtc
import "github.com/pion/webrtc/v3"
// Map the ICE servers returned by /api/turn-credentials to pion's type.
var iceServers []webrtc.ICEServer
for _, s := range credentials.IceServers {
server := webrtc.ICEServer{Username: s.Username, Credential: s.Credential}
switch u := s.URLs.(type) {
case string:
server.URLs = []string{u}
case []any:
for _, v := range u {
server.URLs = append(server.URLs, v.(string))
}
}
iceServers = append(iceServers, server)
}
config := webrtc.Configuration{ICEServers: iceServers}
peerConnection, _ := webrtc.NewPeerConnection(config)
<?php
// PHP typically doesn't run WebRTC directly, fetch the credentials
// (no auth; send an allowed Origin) and hand them to the browser as-is.
// The response is already a valid { iceServers, ttl } RTCConfiguration.
header('Content-Type: application/json');
echo json_encode($credentials); // { "iceServers": [...], "ttl": 86400 }
// Rust WebRTC using webrtc-rs
use webrtc::api::APIBuilder;
use webrtc::ice_transport::ice_server::RTCIceServer;
use webrtc::peer_connection::configuration::RTCConfiguration;
// Build the config from the ICE servers returned by /api/turn-credentials.
let ice_servers = credentials.ice_servers.iter().map(|s| {
let urls = match &s.urls {
serde_json::Value::String(u) => vec![u.clone()],
serde_json::Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
_ => vec![],
};
RTCIceServer {
urls,
username: s.username.clone().unwrap_or_default(),
credential: s.credential.clone().unwrap_or_default(),
..Default::default()
}
}).collect();
let config = RTCConfiguration { ice_servers, ..Default::default() };
let api = APIBuilder::new().build();
let peer_connection = api.new_peer_connection(config).await?;
WebRTC Signaling via WebSocket
Cliqer uses a perfect negotiation pattern for WebRTC signaling. The flow:
- Non-initiator sends
readysignal to announce presence - Initiator creates offer upon receiving
ready - Non-initiator responds with
answer - Both exchange ICE candidates
Signal Types
| Type | Sender | Purpose |
|---|---|---|
ready | Non-initiator | Announce ready to receive offer |
offer | Initiator | SDP offer for connection |
answer | Non-initiator | SDP answer to offer |
candidate | Both | ICE candidate for NAT traversal |
// Non-initiator signals ready
ws.send(JSON.stringify({
event: 'message',
payload: {
type: 'webrtcSignal',
payload: {
room: 'ABC123',
signal: { type: 'ready' }
}
}
}))
// Initiator sends offer
ws.send(JSON.stringify({
event: 'message',
payload: {
type: 'webrtcSignal',
payload: {
room: 'ABC123',
signal: {
type: 'offer',
sdp: peerConnection.localDescription?.sdp
}
}
}
}))
// Handle incoming signals
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'webrtcSignal') {
const { signal, from } = msg.payload
switch (signal.type) {
case 'ready':
// Create and send offer (if initiator)
break
case 'offer':
await peerConnection.setRemoteDescription(signal)
const answer = await peerConnection.createAnswer()
await peerConnection.setLocalDescription(answer)
// Send answer back
break
case 'answer':
await peerConnection.setRemoteDescription(signal)
break
case 'candidate':
await peerConnection.addIceCandidate(signal.candidate)
break
}
}
}
import websocket
import json
WS_URL = "wss://cliqer.io" # wss:// version of BASE_URL
ws = websocket.WebSocket()
ws.connect(f"wss://cliqer.io/ws")
# Non-initiator signals ready
ws.send(json.dumps({
"event": "message",
"payload": {
"type": "webrtcSignal",
"payload": {
"room": "ABC123",
"signal": {"type": "ready"}
}
}
}))
# Initiator sends offer
ws.send(json.dumps({
"event": "message",
"payload": {
"type": "webrtcSignal",
"payload": {
"room": "ABC123",
"signal": {"type": "offer", "sdp": local_description}
}
}
}))
import "github.com/gorilla/websocket"
const wsURL = "wss://cliqer.io" // wss:// version of BASE_URL
conn, _, _ := websocket.DefaultDialer.Dial(wsURL + "/ws", nil)
// Non-initiator signals ready
conn.WriteJSON(map[string]interface{}{
"event": "message",
"payload": map[string]interface{}{
"type": "webrtcSignal",
"payload": map[string]interface{}{
"room": "ABC123",
"signal": map[string]string{"type": "ready"},
},
},
})
// Initiator sends offer
conn.WriteJSON(map[string]interface{}{
"event": "message",
"payload": map[string]interface{}{
"type": "webrtcSignal",
"payload": map[string]interface{}{
"room": "ABC123",
"signal": map[string]string{"type": "offer", "sdp": localSDP},
},
},
})
use tokio_tungstenite::connect_async;
use futures_util::SinkExt;
use serde_json::json;
const WS_URL: &str = "wss://cliqer.io"; // wss:// version of BASE_URL
let (mut ws, _) = connect_async(format!("{}/ws", WS_URL)).await?;
// Non-initiator signals ready
ws.send(json!({
"event": "message",
"payload": {
"type": "webrtcSignal",
"payload": {
"room": "ABC123",
"signal": {"type": "ready"}
}
}
}).to_string().into()).await?;
// Initiator sends offer
ws.send(json!({
"event": "message",
"payload": {
"type": "webrtcSignal",
"payload": {
"room": "ABC123",
"signal": {"type": "offer", "sdp": local_sdp}
}
}
}).to_string().into()).await?;
Server Configuration
| Server | URL | Port | Protocol | Purpose |
|---|---|---|---|---|
| Cloudflare STUN | stun:stun.cloudflare.com | 3478 | UDP | Public IP discovery (always included) |
| Cloudflare TURN | turn:turn.cloudflare.com | 3478 | UDP / TCP | Relay, minted per request |
| Cloudflare TURNS | turns:turn.cloudflare.com | 5349 | TLS/TCP | Secure relay fallback |
The STUN entry is always the fixed Cloudflare STUN URL. The TURN entries (URLs, username, credential) are returned dynamically by Cloudflare's TURN service, so the exact hostnames/ports may differ from those shown, always use the
iceServers array from the response verbatim.Credential Lifetime
- TTL: 86400 seconds (24 hours)
- Source: Credentials are minted on demand by Cloudflare's TURN REST API (
rtc.live.cloudflare.com/v1/turn/keys/…/credentials/generate); each response carries its own short-livedusername/credential.
Rate Limiting & Security
| Endpoint | Auth | Rate limit |
|---|---|---|
/api/turn-credentials | None (Origin allow-list) | Not rate-limited |
This endpoint is not authenticated and not per-IP rate-limited, a presentation audience is often many attendees behind a single NAT IP, so a per-IP cap would break the whole room. Access is gated by an Origin allow-list instead; requests from a disallowed or missing origin return
403 Forbidden.