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).
ICE server configuration
Section titled “ICE server configuration”Fetch STUN/TURN servers before creating an RTCPeerConnection:
GET /api/v1/webrtc/ice-serversAuthorization: Bearer <jwt>{ "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).
Signaling over the WebSocket
Section titled “Signaling over the WebSocket”SDP offers/answers and ICE candidates are exchanged as WebSocket messages. There are two paths.
Encrypted signaling (recommended)
Section titled “Encrypted signaling (recommended)”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.
Plaintext signaling (legacy)
Section titled “Plaintext signaling (legacy)”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 |
Signaling rate limits
Section titled “Signaling rate limits”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.
Disconnect handling
Section titled “Disconnect handling”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.
Media encryption
Section titled “Media encryption”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 history
Section titled “Call history”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 call history
Section titled “Get call history”GET /api/v1/calls/history?limit=50&offset=0&status=allAuthorization: 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) |
{ "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.
Log a call event
Section titled “Log a call event”POST /api/v1/calls/logAuthorization: Bearer <jwt>{ "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.
{ "success": true, "call_id": "9f0c…" }Mark calls as read
Section titled “Mark calls as read”Clears the missed-call badge for specific entries (maximum 100 IDs per request):
POST /api/v1/calls/mark-readAuthorization: Bearer <jwt>{ "call_ids": ["9f0c…", "8e1d…"] }{ "success": true, "marked_count": 2 }Get unread call count
Section titled “Get unread call count”GET /api/v1/calls/unread-countAuthorization: Bearer <jwt>{ "success": true, "unread_count": 3 }Missed-call push notifications
Section titled “Missed-call push notifications”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.
