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: | WebSocket:
Get TURN Credentials
Retrieve temporary TURN server credentials for WebRTC connections.
Endpoint: GET /api/turn-credentials
Authentication Required — This endpoint requires a valid session cookie. Only authenticated Cliqer users can retrieve TURN credentials.
interface TurnCredentials {
username: number
password: string
ttl: number
uris: string[]
}
const credentials: TurnCredentials = await $fetch('/api/turn-credentials')
import requests
BASE_URL = "$BASE_URL"
session = requests.Session()
# First authenticate to get session cookie
session.post(f"$BASE_URL/auth/login", json={"email": "...", "password": "..."})
response = session.get(f"$BASE_URL/api/turn-credentials")
credentials = response.json()
const baseURL = "$BASE_URL"
// Use http.Client with CookieJar for session management
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
// First authenticate to get session cookie
// ...
resp, _ := client.Get(baseURL + "/api/turn-credentials")
defer resp.Body.Close()
var credentials struct {
Username int `json:"username"`
Password string `json:"password"`
TTL int `json:"ttl"`
URIs []string `json:"uris"`
}
json.NewDecoder(resp.Body).Decode(&credentials)
use reqwest::Client;
use serde::Deserialize;
#[derive(Deserialize)]
struct TurnCredentials {
username: i64,
password: String,
ttl: i32,
uris: Vec<String>,
}
// Client with cookie store for session management
let client = Client::builder()
.cookie_store(true)
.build()?;
const BASE_URL: &str = "$BASE_URL";
// First authenticate to get session cookie
// ...
let credentials: TurnCredentials = client
.get(format!("{}/api/turn-credentials", BASE_URL))
.send()
.await?
.json()
.await?;
Response:
{
"username": 1705319400,
"password": "base64encodedhmac==",
"ttl": 86400,
"uris": [
"stun:stun.cloudflare.com:3478",
"turn:turn.cliqer.io:3478?transport=udp",
"turn:turn.cliqer.io:3478?transport=tcp",
"turns:turn.cliqer.io:443?transport=tcp"
]
}
Using TURN Credentials
Browser (JavaScript)
const credentials = await $fetch('/api/turn-credentials')
const peerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.cloudflare.com:3478' },
{
urls: 'turns:turn.cliqer.io:443',
username: credentials.username.toString(),
credential: credentials.password
}
]
})
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
import asyncio
from aiortc import RTCPeerConnection, RTCConfiguration, RTCIceServer
import requests
BASE_URL = "$BASE_URL"
session = requests.Session()
# Authenticate first (session cookie required)
credentials = session.get(f"$BASE_URL/api/turn-credentials").json()
config = RTCConfiguration(iceServers=[
RTCIceServer(urls="stun:stun.cloudflare.com:3478"),
RTCIceServer(
urls="turns:turn.cliqer.io:443",
username=str(credentials["username"]),
credential=credentials["password"]
)
])
pc = RTCPeerConnection(configuration=config)
// Go WebRTC using pion/webrtc
import "github.com/pion/webrtc/v3"
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.cloudflare.com:3478"}},
{
URLs: []string{"turns:turn.cliqer.io:443"},
Username: fmt.Sprintf("%d", credentials.Username),
Credential: credentials.Password,
},
},
}
peerConnection, _ := webrtc.NewPeerConnection(config)
<?php
// PHP typically doesn't run WebRTC directly
// Fetch credentials server-side with authenticated session and pass to frontend
// Note: Requires valid session cookie from authenticated user
// Return to frontend for browser WebRTC
header('Content-Type: application/json');
echo json_encode([
'iceServers' => [
['urls' => 'stun:stun.cloudflare.com:3478'],
[
'urls' => 'turns:turn.cliqer.io:443',
'username' => (string)$credentials['username'],
'credential' => $credentials['password']
]
]
]);
// Rust WebRTC using webrtc-rs
use webrtc::api::APIBuilder;
use webrtc::ice_transport::ice_server::RTCIceServer;
use webrtc::peer_connection::configuration::RTCConfiguration;
let config = RTCConfiguration {
ice_servers: vec![
RTCIceServer {
urls: vec!["stun:stun.cloudflare.com:3478".to_string()],
..Default::default()
},
RTCIceServer {
urls: vec!["turns:turn.cliqer.io:443".to_string()],
username: credentials.username.to_string(),
credential: credentials.password.clone(),
..Default::default()
},
],
..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://v2.cliqer.io" # wss:// version of BASE_URL
ws = websocket.WebSocket()
ws.connect(f"wss://v2.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://v2.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://v2.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 |
| Cliqer STUN | stun:rtc.cliqer.io | 3478 | UDP | NAT traversal |
| Cliqer TURN (UDP) | turn:turn.cliqer.io | 3478 | UDP | Relay (fastest) |
| Cliqer TURN (TCP) | turn:turn.cliqer.io | 3478 | TCP | Relay (firewall-friendly) |
| Cliqer TURNS | turns:turn.cliqer.io | 443 | TLS/TCP | Secure relay fallback |
Credential Lifetime
- TTL: 86400 seconds (24 hours)
- Rotation: Credentials rotate automatically via HMAC
- Algorithm: SHA-1 HMAC signed with server secret
Rate Limiting & Security
| Endpoint | Auth | Requests/Minute | Block Duration |
|---|---|---|---|
/api/turn-credentials | Required | 10 | 5-60 minutes (progressive) |
TURN credentials require authentication and are rate-limited by IP address. Unauthenticated requests return
401. Excessive requests result in temporary blocks.