Voice and video calls
Calls happen inside a conversation: its members are the people who can be called. Signaling goes over the chat connection you already have; audio and video go through our media servers (an SFU), never through your backend.
What you get
- 1:1 and group calls, audio or video.
- Ringing on every device the callee is signed in on; the first device to answer takes the call and the others end with
call.endedHereset toanswered_elsewhere. - Push notifications for callees who are offline, and a missed-call push when nobody answers.
- Missed, declined, busy and cancelled calls recorded with an end reason.
- Mute and camera on/off, screen sharing, switching microphone and camera while in a call.
- Simulcast: each viewer can pick the quality of each tile (
off,low,medium,high). - Active speaker, per-participant audio level and network quality.
- Automatic reconnect after network changes, and TURN relay for networks that block direct UDP.
- Webhooks
call.startedandcall.ended. - Server moderation: end a call, remove a participant, mute a participant (REST and dashboard Calls page).
- Participants per call by plan: Free 4, Starter 8, Growth 16, Scale 32, Enterprise 50.
Quick start
import { Chat } from "byotalk";
import { CallClient } from "byotalk/calls";
const chat = new Chat({ env: "env_…", token: getToken });
await chat.connect();
const calls = new CallClient(chat);
// Someone is calling this user.
calls.on("incoming", async (call) => {
// show your ringing UI, then:
await call.accept(); // or: await call.decline();
});
// Call a conversation.
const call = await calls.start(conversationId, { video: true });
// Each participant: userId, audioTrack, videoTrack, screenTrack, audioMuted, videoMuted,
// speaking, audioLevel, inCall, isLocal
call.on("participants", (list) => render(list));
call.on("state", (s) => console.log(s)); // incoming | connecting | connected | reconnecting | ended
await call.setMicrophoneEnabled(false);
await call.setCameraEnabled(true);
await call.switchCamera();
await call.startScreenShare();
await call.setVideoQuality(userId, "low");
const stats = await call.getStats();
await call.leave();
Attaching tracks
// Video (and screen share): one element per tile.
video.srcObject = new MediaStream([p.videoTrack]);
// Audio: one <audio autoplay> per remote participant with an audioTrack.
// Never play the local user's own audio.
if (!p.isLocal && p.audioTrack) audio.srcObject = new MediaStream([p.audioTrack]);
Errors
| Code | When | What happens |
|---|---|---|
media_permission_denied |
Microphone or camera permission denied | Microphone denied: start / accept rejects. Camera denied: the call continues audio-only and emits error |
media_device_not_found |
No microphone or camera | start / accept rejects |
media_device_busy |
Device in use by another app | start / accept rejects |
calls_unavailable |
503: no media server available | Retry after retryAfterMs |
call_ended |
409: the call has already ended | |
plan_limit_reached |
429: participants per call over the plan limit |
React Native
Install react-native-webrtc, call its registerGlobals() at startup, and pass its mediaDevices:
import { mediaDevices, registerGlobals } from "react-native-webrtc";
registerGlobals();
const calls = new CallClient(chat, { mediaDevices });
The media client picks the React Native handler automatically. Native incoming-call UI (CallKit on iOS, ConnectionService on Android, for example with react-native-callkeep) and VoIP push are your app's job; we have not verified those integrations yet.
REST API
| Method & path | Caller | Notes |
|---|---|---|
POST /v1/calls |
Client, server | { conversationId, kind: audio|video, metadata?, callerId? }; callerId with server keys |
GET /v1/calls |
Client, server | ?conversationId=&userId=&live=true|false&limit=1..100&cursor= → { data, hasMore, nextCursor } |
GET /v1/calls/{id} |
Client, server | A call |
POST /v1/calls/{id}/join |
Client | Answer or rejoin |
POST /v1/calls/{id}/decline |
Client | |
POST /v1/calls/{id}/leave |
Client | |
POST /v1/calls/{id}/end |
Server, dashboard | Ends the call for everyone (ended_by_server) |
POST /v1/calls/{id}/participants/{userId}/remove |
Server, dashboard | |
POST /v1/calls/{id}/participants/{userId}/mute |
Server, dashboard | { source: mic|camera|screen }; 400 invalid_request if the user is not connected to media |
A call:
{
"id": "call_01J9…",
"conversationId": "conv_01J9…",
"kind": "video",
"status": "ringing | active | ended",
"createdBy": "alice",
"createdAt": "2026-10-05T10:00:00.000Z",
"answeredAt": null,
"endedAt": null,
"endReason": "completed | missed | declined | cancelled | busy | failed | ended_by_server | null",
"durationSeconds": 0,
"metadata": {},
"participants": [
{ "userId": "bob", "state": "ringing | joining | joined | left | declined | missed | busy", "invitedAt": "…", "joinedAt": null, "leftAt": null }
]
}
Webhooks
call.started (when the call starts ringing) and call.ended carry the call:
{ "type": "call.ended", "data": { "call": { "id": "call_01J9…", "status": "ended", "endReason": "completed", … } } }
Push notifications
Callees with no open connection get a high-priority data push, with a TTL equal to the ring timeout (45 s):
{ "type": "call.incoming", "callId": "call_01J9…", "conversationId": "conv_01J9…", "kind": "video", "callerId": "alice" }
If nobody answers, a { "type": "call.missed", … } push follows with the same collapse id, so it replaces the incoming one.
Self-hosting
The media node needs UDP and TCP ports from 40000 up open to the internet (one port per CPU core). Run coturn as a TURN server for networks that block those ports.