Skip to content

Calls and WebRTC

Zentalk calls are peer-to-peer WebRTC sessions. The API provides three parts: STUN/TURN configuration, signaling relay over the WebSocket connection, and call history endpoints. All endpoints require a Bearer JWT (see Authentication).

Fetch STUN/TURN servers before creating an RTCPeerConnection:

GET /api/v1/webrtc/ice-servers
Authorization: Bearer <jwt>
Response
{
"success": true,
"ice_servers": [
{ "urls": ["stun:stun.example.com:3478"] },
{
"urls": ["turn:turn.example.com:3478"],
"username": "1760003600:0x1a2b…",
"credential": "base64…"
}
],
"message": "ICE servers retrieved successfully"
}

Each entry maps directly onto the RTCIceServer dictionary:

const { ice_servers } = await res.json();
const pc = new RTCPeerConnection({ iceServers: ice_servers });

TURN deployments may issue time-limited HMAC credentials derived per user (default TTL 3600 seconds), so re-fetch the configuration when starting a new call rather than caching credentials long-term. If no ICE servers are configured, the endpoint falls back to a public Google STUN server (stun:stun.l.google.com:19302).

SDP offers/answers and ICE candidates are exchanged as WebSocket messages. There are two paths.

The encrypted_call_signal event wraps every signal in an E2EE envelope encrypted with the peers’ Double Ratchet session. The server sees only routing metadata (from, to, call_id, signal_type) and relays the blob without decrypting it — SDP contents, IP addresses, and ICE candidates are hidden from the server.

{
"type": "encrypted_call_signal",
"payload": {
"to": "0x3c4d…",
"call_id": "call_01H…",
"signal_type": "offer",
"encrypted_signal": "base64…",
"e2ee_header": "base64…",
"e2ee_version": 1
}
}

signal_type values: offer, answer, ice, rejected, ended, renegotiation_offer, renegotiation_answer (renegotiation is used for mid-call changes such as screen sharing). The server overwrites from with the authenticated sender’s address, so signals cannot be spoofed.

The plaintext events carry the same flow with server-visible SDP:

Event Payload
call_offer { from, to, sdp, call_type, call_id }call_type: audio or video
call_answer { from, to, sdp, call_id }
ice_candidate { from, to, candidate, call_id }candidate is a JSON string
call_reject { from, to, reason, call_id }busy, declined, unavailable
call_end { from, to, reason, call_id }hangup, timeout, error, disconnect

WebRTC messages have their own rate limits, separate from the general WebSocket budget:

Message class Burst Sustained
ICE candidates (ice_candidate, encrypted signal_type: "ice") 30 100/minute
Other signaling (offer, answer, reject, end) 5 10/minute

A single call may carry at most 50 ICE candidates in total from both parties. Messages over a limit are dropped; the connection stays open. Full policy: Rate limits.

If a party’s WebSocket drops during an active call, the server ends the call and sends the other party a call_end event with reason: "disconnect". Clients should tear down the peer connection when they receive it.

WebRTC media is always transported over DTLS-SRTP. In addition, 1-to-1 calls apply an application-level end-to-end encryption layer: each media frame is encrypted with AES-GCM-256 via Insertable Streams before it reaches the RTP stack, using keys derived from the peers’ E2EE session. Combined with encrypted signaling, neither media nor signaling content is visible to the relay infrastructure.

Call logs are recorded client-side via POST /api/v1/calls/log and queried per user. All four endpoints require authentication and return 401 with a standard error body otherwise.

GET /api/v1/calls/history?limit=50&offset=0&status=all
Authorization: Bearer <jwt>
Query parameter Type Description
limit integer 1–100, default 50
offset integer Pagination offset, default 0
status string missed, completed, or all (default)
Response
{
"success": true,
"calls": [
{
"id": "9f0c…",
"caller_address": "0x1a2b…",
"callee_address": "0x3c4d…",
"call_type": "voice",
"status": "completed",
"initiated_at": "2026-07-14T12:00:00Z",
"answered_at": "2026-07-14T12:00:04Z",
"ended_at": "2026-07-14T12:05:04Z",
"duration": 300,
"is_read_by_caller": true,
"is_read_by_callee": true,
"created_at": "2026-07-14T12:05:05Z",
"caller_name": "Alice",
"callee_name": "Bob",
"is_outgoing": true,
"is_read": true
}
],
"total": 1,
"unread_count": 0
}

Group call entries additionally carry group_id and participant_addresses; declined calls carry decline_reason.

POST /api/v1/calls/log
Authorization: Bearer <jwt>
Request
{
"caller_address": "0x1a2b…",
"callee_address": "0x3c4d…",
"call_type": "voice",
"status": "completed",
"initiated_at": "2026-07-14T12:00:00Z",
"answered_at": "2026-07-14T12:00:04Z",
"ended_at": "2026-07-14T12:05:04Z",
"duration": 300
}
Field Allowed values
call_type voice, video, group_voice, group_video
status missed, declined, completed, failed, busy, cancelled
initiated_at, answered_at, ended_at RFC 3339 timestamps (answered_at, ended_at optional)
decline_reason Optional string, for declined calls
group_id, participant_addresses Optional, for group calls

The authenticated user must be the caller or the callee, otherwise the request fails with 403 FORBIDDEN.

Response
{ "success": true, "call_id": "9f0c…" }

Clears the missed-call badge for specific entries (maximum 100 IDs per request):

POST /api/v1/calls/mark-read
Authorization: Bearer <jwt>
Request
{ "call_ids": ["9f0c…", "8e1d…"] }
Response
{ "success": true, "marked_count": 2 }
GET /api/v1/calls/unread-count
Authorization: Bearer <jwt>
Response
{ "success": true, "unread_count": 3 }

On deployments with Web Push enabled, a missed call triggers a push notification to the callee’s registered push subscriptions, so users learn about missed calls even without an open WebSocket connection.