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.endedHere set to answered_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.started and call.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.