Audio and video calls
Users can make one-to-one and group voice and video calls in the browser. The Web SDK handles all call signaling and media connections: starting, ringing for incoming calls, answering, declining, hanging up, ringing on several devices at once, recovering after disconnects, connecting to the media service, and turning on the microphone and camera. Your page only shows the call UI and attaches the audio and video tracks the SDK gives you to <video> and <audio> elements.
Audio and video data is carried by the media service the IM service provides. You don't need to deploy or configure any media server, or integrate another audio/video SDK: the SDK loads the media components it needs on the first call.
// 发起一对一视频通话
const call = await im.calls.start({ to_user: 'bob', media: 'video' });
// 对方的画面和声音
call.on('track', ({ username, local }) => {
if (!local && username) call.attach(username, document.querySelector<HTMLVideoElement>('#remote')!);
});
// 挂断
await call.hangup();For the full call rules (busy, multiple devices, group call size, end reasons, billing), see the server docs on audio and video calls.
Preparation
Turn on audio and video
Audio and video are off by default for apps; turn on rtc_enabled in the runtime policies. In addition:
- Once new billing is enabled, the app's audio/video balance must be positive; before switching to new billing, the original plan's switch applies;
- The app must be in good standing, within its concurrent call quota, with the media service available.
Use im.config.rtc_enabled to decide whether to show call entries, and listen for config changes. Balance and service status may change after the button is shown, so whether a call can be started or answered follows the server's result:
function updateCallButtons() {
render('call-buttons', { visible: im.config?.rtc_enabled === true });
}
updateCallButtons();
im.on('config.changed', updateCallButtons);When it isn't allowed, the call is refused with permission_denied (rtc_disabled). When you get this error, check the app's runtime policy, audio/video balance, top-up approval and the date of switching to new billing, rather than letting users keep dialing.
Browser requirements
- Secure pages: browsers only allow HTTPS pages (and
localhostfor local debugging) to use the microphone and camera. - Microphone and camera permission: once connected, the SDK turns on the microphone automatically, plus the camera for video calls, and the browser shows a permission prompt the first time. For what to do when the user denies permission or the device isn't available, see Microphone and camera.
- Call from a user click: browsers require a user click before a page can play audio or open devices. Call
start(),join()andresume()from a button's click handler.
When loading with a script tag
With the global build (loading the Web SDK with a <script> tag), the media components aren't included in the global build and must be loaded on the page separately, see Initialization. Installing from npm needs no extra steps.
Start a one-to-one call
start() takes the other user's username and the media type: audio for voice, video for video (not switchable during the call). You can attach custom fields ext (such as a business order number), delivered to the other side with the incoming call.
import { DRError } from '@deeprespond/im-web';
try {
const call = await im.calls.start({ to_user: 'bob', media: 'video', ext: { order_id: '8812' } });
const { call: info } = call.getSnapshot();
if (info.status === 'ended' && info.end_reason === 'busy') {
showToast('对方忙线中');
} else {
render('call-screen', call);
}
} catch (err) {
if (err instanceof DRError && err.code === 'call_in_progress') {
showToast('你正在另一个通话中,请先挂断');
} else if (err instanceof DRError && err.code === 'user_blocked') {
showToast('对方拒绝了你的通话');
} else {
throw err;
}
}- Once started, you're in the call: the SDK immediately connects to the media service and turns on the microphone (plus the camera for video calls) while waiting for the other side to answer; you can show your own preview now.
- If the other user is in another call,
start()still resolves, but the returned call has already ended (end_reasonbusy), with no ringing and no media connection. - If you're already in a call, it's refused with
call_in_progress, with your current call indetails.call_id. Once the user confirms, hang that one up before starting. - The same rules as sending one-to-one messages apply: refused with
user_blockedif the other user blocked you,not_friendif the app only allows messages between friends and you aren't friends, anduser_mutedif you're globally muted for one-to-one chat. - Starting too often is refused with
rate_limited, which the SDK doesn't retry; on network errors the SDK retries automatically without starting twice.
Next, once the other side answers, the call's status becomes active, see Show call state.
Start a group call
Start a group call in a group, with usernames as the group members to ring; you can also invite nobody and wait for other members to join from the group call banner.
import { DRError } from '@deeprespond/im-web';
declare const groupId: string;
try {
const call = await im.calls.start({ to_group: groupId, usernames: ['lisi', 'wangwu'], media: 'audio' });
// 没有振铃的人(正在其他通话中、在群里被禁言等)
const notRinging = (call.getSnapshot().call.members ?? []).filter((m) => m.role === 'invitee' && m.state !== 'ringing');
if (notRinging.length > 0) showToast(`${notRinging.map((m) => m.username).join('、')} 暂时无法接听`);
} catch (err) {
if (err instanceof DRError && err.code === 'already_exists') {
// 群里已有进行中的群通话:改为加入它
const existing = await im.calls.get(String(err.details?.call_id));
await existing.join();
} else {
throw err;
}
}- The starter must be able to speak in the group (not muted in it, and the owner or an admin if the group is muted for everyone). Invitees must be group members who can speak in the group, or they don't ring; people in another call are recorded as busy (
busy) and don't ring. - Each group has only one group call at a time. If the group already has an ongoing one, it's refused with
already_exists, with that call indetails.call_id; join it instead. - The maximum number of people in the call at once is
max_group_call_participantsfrom the runtime config (including the starter, set by the plan, 0 means no limit), and one invitation can't include more than that minus one; beyond that the SDK refuses locally withlocal_validation(too_many_invitees). - Group calls don't check personal blocklists.
Receive incoming calls
When an incoming call arrives (a one-to-one call or a group call invitation), the SDK fires the call.incoming event and starts ringing. Every tab in the same browser gets this event; the tab with alert true is the one responsible for alerting, so only play the ringtone there to avoid several tabs ringing at once:
im.on('call.incoming', ({ call, alert }) => {
const { inviter, group, call: info } = call.getSnapshot();
render('incoming', {
from: inviter?.nickname || inviter?.username,
avatar: inviter?.avatar_url,
group: group?.name ?? null, // 群通话的群,一对一为 null
media: info.media, // 'audio' 或 'video'
});
if (alert) void ringtone.play();
// 停止振铃:对方取消、超时、在其他设备上接听或拒绝、本人接听或拒绝
const off = call.subscribe(() => {
if (!call.getSnapshot().ringing) {
ringtone.pause();
render('incoming', null);
off();
}
});
});- Ringing calls:
im.calls.incomingis every incoming call ringing right now. Users may receive several at once and choose which to answer. - Calls while offline: when the page opens or the network recovers, the SDK queries ringing incoming calls and fires
call.incomingfor any not yet notified; the same call never fires twice. - Ringing time: set by the runtime policy
rtc_ring_timeout_seconds(60 seconds by default). If not answered in time, the SDK stops ringing (the snapshot'sringingbecomesfalse); a one-to-one call ends withno_answer, and in a group call your invitation is recorded as missed (missed). - Ringing on several devices: once you answer or decline on another device (another browser, the mobile app), ringing stops here. When answered on another device, the
call.changedevent'schange.kindisanswered_elsewhere, and you can show "answered on another device". - Busy: while you're in a call, new one-to-one calls are judged busy by the server and don't ring; group call invitations are recorded as busy and don't ring. Browsers can't tell whether the user is on a phone call, so the SDK never declines as busy automatically; your page can call
reject({ reason: 'busy' })as your business needs.
Answer and decline
declare const call: import('@deeprespond/im-web').CallHandle;
// “接听”按钮的点击事件中
async function onAccept() {
await call.join();
render('call-screen', call);
}
// “拒绝”按钮
async function onDecline() {
await call.reject();
}join()answers an incoming call or joins a group call; on success the SDK connects to the media service, turns on the microphone and camera, and the call becomesactive(in a group call, once two people are in it for the first time).reject()declines. A one-to-one call ends (end_reasonrejected); in a group call only your invitation ends (declined), without affecting others. Your other devices stop ringing.- If you're already in another call,
join()is refused withcall_in_progress, with that call indetails.call_id; hang it up first. - If the call has ended (the caller canceled), it's refused with
call_ended; if you already answered on another device, withcall_in_progress(joined_on_other_device). - If the group call is full, it's refused with
limit_exceeded(call_full).
Show call state
All of a call's state is in the handle's snapshot; subscribe with subscribe() to update the UI when it changes:
call.subscribe(() => {
const s = call.getSnapshot();
render('call-screen', {
status: s.call.status, // 'ringing' 等待接听 / 'active' 通话中 / 'ended' 已结束
seconds: s.call.duration_seconds, // 接通后的时长
media: s.media, // 媒体连接:'connecting'、'connected'、'reconnecting' 等
microphone: s.local.microphone,
camera: s.local.camera,
others: s.remote, // 其他人是否开着麦克风、摄像头
});
});| Snapshot field | Description |
|---|---|
call | The call, see Call: status status, members members, end reason end_reason and more |
self | Your state in the call: state is your member state, joined_on_this_device says whether you're in the call on this device |
ringing | Whether it's ringing here |
media | This tab's media connection: none / connecting / connected / reconnecting (network hiccup, reconnecting) / disconnected |
local | Whether your microphone and camera are on |
remote | Other members' media: one entry each, with audio and video saying whether their microphone and camera are on |
media_tab, resumable | See Multiple tabs and Resume a call after a page refresh |
To know "what happened" (who joined, declined, left), listen for the call.changed event, where change.kind describes the change:
im.on('call.changed', ({ call, change }) => {
if (!change) return;
const who = change.usernames.filter(Boolean).join('、');
switch (change.kind) {
case 'joined': showToast(`${who} 加入了通话`); break;
case 'declined': showToast(`${who} 拒绝了邀请`); break;
case 'left': showToast(`${who} 离开了通话`); break;
case 'answered_elsewhere': showToast('已在其他设备接听'); break;
case 'ended': render('call-ended', call.getSnapshot().call.end_reason); break;
}
});change.kind | Description |
|---|---|
created | You started a call on another device; you can show "in a call" with a hang-up option here |
invited | New invitations in a group call |
joined | Someone answered or joined |
answered_elsewhere | You answered on another device |
declined | Someone declined |
missed | A group call invitation timed out unanswered |
left | Someone left or was removed, with the leave reason in reason |
canceled | A ringing group call invitation was canceled |
ended | The call ended, with the end reason in reason |
Your own actions on this page (answering, declining, hanging up) also fire call.changed, with change null.
Render video on the page
The SDK hands you the call's audio and video tracks (standard MediaStreamTracks) through the track event, both your own (local true) and other members'; trackRemoved fires when someone turns off their camera or leaves. The simplest approach is to call attach() when tracks change, attaching all of a member's tracks to one element:
declare const call: import('@deeprespond/im-web').CallHandle;
const me = im.auth.currentUser?.username ?? '';
const localVideo = document.querySelector<HTMLVideoElement>('#local')!;
localVideo.muted = true; // 自己的预览不要播放自己的声音
const remoteVideo = document.querySelector<HTMLVideoElement>('#remote')!;
function refresh(username: string | null, local: boolean) {
if (local) call.attach(me, localVideo);
else if (username) call.attach(username, remoteVideo);
}
call.on('track', ({ username, local }) => refresh(username, local));
call.on('trackRemoved', ({ username, local }) => refresh(username, local));<video id="local" autoplay playsinline muted></video>
<video id="remote" autoplay playsinline></video>attach(username, element)sets all of this member's current tracks (audio and video) as the element'ssrcObject. You can also pass the member'smedia_uid. For voice calls,<audio autoplay>is enough.- In group calls, prepare one element per member and attach by the
usernamein thetrackevent; take member order and nicknames from the snapshot'scall.members. - Your own preview element must be muted (
muted), or you'll hear your own echo. - To show "muted" or "camera off" for others, use each person's
audioandvideoin the snapshot'sremote.
In-call actions
Mute and toggle the camera
// 静音 / 取消静音
await call.setMicrophoneEnabled(!call.getSnapshot().local.microphone);
// 关闭 / 打开摄像头(只用于视频通话)
await call.setCameraEnabled(!call.getSnapshot().local.camera);- Other members see your state change (in
remoteof their snapshots). - Turning on the camera in a voice call is refused with
invalid_state(audio_call). A video call with the camera off is still recorded as a video call. - Before media is connected, it's refused with
invalid_state(media_not_connected). - The SDK uses the browser's default microphone and camera, and doesn't currently offer APIs to choose or switch devices.
Invite more people
In a group call, members in the call can invite other group members. Members who declined, missed or left can be invited again and ring again. Each person's result is returned, and one failing doesn't affect the others:
const results = await call.invite(['zhaoliu', 'sunqi']);
for (const r of results) {
if ('code' in r) showToast(`${r.username} 无法邀请:${r.code}`);
else if (r.status === 'busy') showToast(`${r.username} 正在通话中`);
}Successful results have status invited (ringing), busy (in another call, not ringing) or already_in_call (already in this call); failed ones carry a code, such as not_group_member, group_muted, rate_limited (inviting too often) or limit_exceeded (call full). One-to-one calls can't invite others.
Remove members
Clients can't remove others from a call. When you need to (for example to handle violations), your server calls the call management API; the removed person's snapshot updates as usual and their media connection is closed.
Send data messages
sendData() sends data (a string or Uint8Array) to the other members in the call over the media connection, suited to syncing state alongside the call such as "raise hand" or whiteboard strokes. Data only goes to people in the call right now and isn't stored.
await call.sendData(JSON.stringify({ action: 'raise_hand' }));
call.on('data', ({ username, data }) => {
const payload = JSON.parse(new TextDecoder().decode(data)) as { action: string };
if (payload.action === 'raise_hand') showToast(`${username} 举手了`);
});States such as "muted" are already provided by the SDK (the snapshot's remote), so you don't need data messages for them.
Hang up and end
// 挂断:接通前为取消,接通后为离开,正在振铃时等同拒绝
await call.hangup();
// 结束群通话:全部成员离开(只有发起人、群主和群管理员可以)
await call.end();- In a one-to-one call, either side hanging up ends the call. In a group call, hanging up only means you leave while others continue; you can
join()again afterwards. - The group call's starter, group owner and group admins can end the whole group call with
end(); others calling it are refused withpermission_denied(not_host). For one-to-one calls,end()is the same as hanging up. - A group call ends automatically when everyone in it has left; it also ends after 60 seconds with only one person left and no ringing invitations.
- Any of your devices can hang up.
- After hanging up, the SDK disconnects media and turns off the microphone and camera.
Once the call ends, the snapshot's call.status is ended and call.end_reason the end reason, and the snapshot stops changing. You decide the text shown; the table below is only a suggestion, see End reasons for full details:
end_reason | Prompt for the caller | Prompt for the callee |
|---|---|---|
completed | Call duration | Call duration |
canceled | Canceled | Caller canceled |
rejected | Declined | You declined |
busy | Busy | Missed call |
no_answer | No answer | Missed call |
connection_lost | Call interrupted | Call interrupted |
| Others | Call ended | Call ended |
New end reasons may be added; show unrecognized values as "Call ended".
Group call banner
When a group call starts, the number of people in it changes, or it ends, all online group members get a notification, used to show a "3 people in a call" banner with a join entry on the group chat page. The SDK records it locally and fires the groupCall.changed event, and im.calls.groupCallBanner(groupId) synchronously returns the group's current banner (null if none).
After a page refresh, or if you weren't a group member when the notification went out, the SDK may not know about the group's call yet. When opening a group's chat page, call im.calls.groupCall(groupId) once:
declare const groupId: string;
function renderBanner() {
const banner = im.calls.groupCallBanner(groupId);
render('group-call-banner', banner && {
text: banner.joined_count > 1 ? `${banner.joined_count} 人正在通话` : '等待其他成员加入',
media: banner.media,
});
}
// 打开群聊页面时
await im.calls.groupCall(groupId).catch(() => null);
renderBanner();
const off = im.on('groupCall.changed', () => renderBanner());
// 点击横幅加入
async function onJoinFromBanner() {
const call = await im.calls.groupCall(groupId);
if (call) await call.join();
}The banner's count may be briefly off; the call snapshot is authoritative for members.
Multiple tabs
Multiple tabs of the same browser are the same device, and only one tab can be in a call at a time:
- Incoming calls fire
call.incomingin every tab; play the ringtone in the tab withalerttrue. Whichever tab answers holds the media (microphone, camera, and the other side's video and audio). - In other tabs, this call's snapshot has
media_tabother, so you can show "call in progress in another tab". Callingjoin(),setMicrophoneEnabled()orsetCameraEnabled()there is refused withinvalid_state(media_in_other_tab);hangup(),end()andinvite()can be called from any tab. - When the tab in the call is closed, the call doesn't drop immediately: in other tabs this call's
resumablebecomestrue, and the user can click to resume there, see the next section.
call.subscribe(() => {
const s = call.getSnapshot();
if (s.media_tab === 'other') render('call-hint', '通话在其他标签页中进行');
else if (s.resumable) render('call-hint', '点击恢复通话');
});Disconnection and recovery
The SDK handles heartbeats and media credential renewal automatically; you don't need to.
- Network hiccups and switching networks: the media connection reconnects automatically, during which the snapshot's
mediaisreconnecting, so you can show "poor network, reconnecting". As long as it recovers within 45 seconds, the call isn't affected. - Offline for over 45 seconds: the server considers you gone. A one-to-one call ends with
connection_lost; in a group call you leave (connection_lost) and canjoin()again once the network recovers. - After the network recovers: the SDK queries your ongoing call and ringing incoming calls and reconciles with local state: calls that ended update to
ended, and calls that arrived while offline start ringing.
Resume a call after a page refresh
When a user refreshes the page during a call (or closes the tab in the call), the server still considers them in the call. Once the page reopens and signs in, the SDK finds the call, im.calls.current is it, and the snapshot's resumable is true. The SDK sends heartbeats on the user's behalf right away, but browsers require a click before turning the microphone back on and playing audio, so prompt the user to "click to resume the call" and call resume() from the click. Prompt quickly: if not resumed within 45 seconds, the server considers the user gone.
function checkResumable() {
const call = im.calls.current;
if (call?.getSnapshot().resumable) {
render('resume-button', {
onClick: async () => {
await call.resume();
render('call-screen', call);
},
});
}
}
// 登录、重新连接后,SDK 同步完通话状态时
im.on('sync.completed', ({ modules }) => {
if (modules.includes('calls')) checkResumable();
});Calling resume() when resumable isn't true is refused with invalid_state (not_resumable).
Microphone and camera
When connecting, resuming a call, and on setMicrophoneEnabled(true) or setCameraEnabled(true), the SDK turns on the microphone and camera:
- If the user hasn't granted permission (or denied the browser's prompt), it's refused with
permission_required, with the missing permissions (microphone,camera) indetails.permissions; - If the device doesn't exist or is in use by another program, it's refused with
device_error, withmicrophoneorcameraindetails.deviceandnot_foundorbusyindetails.reason.
When join() or resume() hits these two errors, the call is already connected; only that device isn't on (it's false in the snapshot's local). Before the other side can hear you, guide the user to grant permission, then call setMicrophoneEnabled(true):
import { DRError } from '@deeprespond/im-web';
declare const call: import('@deeprespond/im-web').CallHandle;
try {
await call.join();
} catch (err) {
if (err instanceof DRError && err.code === 'permission_required') {
render('permission-guide', err.details?.permissions); // 引导用户在浏览器中允许麦克风、摄像头
} else if (err instanceof DRError && err.code === 'device_error') {
showToast(err.details?.device === 'camera' ? '摄像头不可用' : '麦克风不可用');
} else {
throw err;
}
}
// 用户授权后
await call.setMicrophoneEnabled(true);When starting a call, the SDK connects media and opens devices only after start() resolves, so permission problems don't make start() fail. Once media is connected (the snapshot's media is connected) while local.microphone is still false, call setMicrophoneEnabled(true) to get the specific error. You can also request permission once with the browser's navigator.mediaDevices.getUserMedia() before starting.
Runtime policies and plans
| Setting | Effect |
|---|---|
Audio and video calls rtc_enabled | When off, nobody can start, answer or join calls or invite others, refused with permission_denied (rtc_disabled); ongoing calls aren't affected |
Ringing time rtc_ring_timeout_seconds | 60 seconds by default |
Group call size max_group_call_participants | Including the starter, set by the plan, 0 means no limit |
Call records rtc_call_record_enabled | When on, a call record message is written to the conversation after a call ends |
See Runtime policies for these. In addition:
- Before switching to new billing, a plan without audio and video is refused with
rtc_disabledtoo; once new billing is enabled, insufficient balance also refuses starting and answering, see Audio/video balance; - When the app's ongoing calls reach the plan's quota, starting is refused with
limit_exceeded(app_concurrent_calls); - When the app is read-only (such as in arrears), starting and inviting are refused with
app_unavailable, while ongoing calls aren't affected; - When the platform suspends this app's audio and video, starting is refused with
permission_denied(rtc_suspended); when the audio/video service is temporarily unavailable, withpermission_denied(rtc_not_configured). Both are only known when starting, so prompt users according to the reason; - A refusal with
permission_denied(media_provider_unsupported) means this Web SDK version doesn't support the media service the platform provides for the app; upgrade the Web SDK, and tell users "this version doesn't support calls".
Call records
Call history
im.calls.history() returns, page by page, calls you took part in (including missed and declined ones), newest first, kept for 180 days:
const page = await im.calls.history({ limit: 20 });
for (const c of page.items) {
render(c.type === 'single' ? c.peer : c.group_id, c.media, c.end_reason, c.duration_seconds, c.created_at);
}Call record messages in conversations
After a call ends, the server writes a message of type call to the conversation (both parties' one-to-one conversation, or the group conversation for group calls). The display helper renderCallRecord() gives text such as "Call duration 01:32" or "Declined":
import type { MessageView } from '@deeprespond/im-web';
import { renderCallRecord } from '@deeprespond/im-web/render';
declare const message: MessageView;
if (message.type === 'call') {
const text = renderCallRecord(message, { self: im.auth.currentUser?.username ?? '' });
render('call-record', text);
}For the message format, see Call record call; for display helpers, see Messages.
Using it in React
Listen for incoming calls with useDREvent, and subscribe to a call's snapshot with useHandle:
import { useState } from 'react';
import { useDREvent, useHandle } from '@deeprespond/im-web-react';
import type { CallHandle } from '@deeprespond/im-web';
function CallLayer() {
const [call, setCall] = useState<CallHandle | null>(null);
useDREvent('call.incoming', ({ call }) => setCall(call));
const snapshot = useHandle(call);
if (!call || !snapshot || snapshot.call.status === 'ended') return null;
if (snapshot.ringing) {
return (
<div>
<p>{snapshot.inviter?.nickname} 邀请你{snapshot.call.media === 'video' ? '视频' : '语音'}通话</p>
<button onClick={() => void call.join()}>接听</button>
<button onClick={() => void call.reject()}>拒绝</button>
</div>
);
}
return (
<div>
<p>{snapshot.call.status === 'active' ? `通话中 ${snapshot.call.duration_seconds} 秒` : '等待对方接听'}</p>
<button onClick={() => void call.setMicrophoneEnabled(!snapshot.local.microphone)}>
{snapshot.local.microphone ? '静音' : '取消静音'}
</button>
<button onClick={() => void call.hangup()}>挂断</button>
</div>
);
}Attach a member's video to a <video>:
import { useEffect, useRef } from 'react';
import type { CallHandle } from '@deeprespond/im-web';
function MemberVideo({ call, username, self }: { call: CallHandle; username: string; self?: boolean }) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => call.attach(username, el);
update();
const offTrack = call.on('track', (p) => { if (p.username === username) update(); });
const offRemoved = call.on('trackRemoved', (p) => { if (p.username === username) update(); });
return () => { offTrack(); offRemoved(); };
}, [call, username]);
return <video ref={ref} autoPlay playsInline muted={self} />;
}The Vue bindings provide useDREvent and useHandle with the same names and usage.
API reference
im.calls.start()
Starts a one-to-one or group call. Once started you're in the call, and the SDK connects to the media service.
| Parameter | Type | Required | Description |
|---|---|---|---|
p.to_user | string | One of the two | The other user's username for a one-to-one call |
p.to_group | string | One of the two | The group ID for a group call |
p.usernames | string[] | No | Members to ring in a group call, at most max_group_call_participants minus one, not including yourself |
p.media | 'audio' | 'video' | Yes | Voice or video, not switchable during the call |
p.ext | Record<string, string> | No | Custom fields, at most 16, at most 1 KB JSON-encoded, delivered with the incoming call |
Returns: Promise<CallHandle>. If the other side of a one-to-one call is in another call, the returned call has already ended (end_reason busy).
Errors: permission_denied (rtc_disabled audio and video not on for the app; rtc_suspended, rtc_not_configured audio and video temporarily unavailable; media_provider_unsupported this version isn't supported), call_in_progress (you're already in a call, with it in details.call_id), already_exists (the group already has an ongoing group call, in details.call_id), user_blocked, not_friend, user_muted, not_group_member, group_muted, group_disabled, not_found (the other user or group doesn't exist), limit_exceeded (app_concurrent_calls), app_unavailable, rate_limited (call_rate, pair_call_rate, group_call_rate), invalid_argument (such as self_call calling yourself, invalid_ext), local_validation (missing parameters, too_many_invitees).
im.calls.current
Read-only, CallHandle | null: your ongoing call (your member state is joined), including one on another device (self.joined_on_this_device false); null if none.
im.calls.incoming
Read-only, readonly CallHandle[]: incoming calls ringing right now.
im.calls.get()
Gets a call's handle by ID, returning the local one if present, otherwise querying the server.
| Parameter | Type | Required | Description |
|---|---|---|---|
call_id | string | Yes | Call ID |
Returns: Promise<CallHandle>.
Errors: not_found (the call doesn't exist or you can't see it: one-to-one calls are visible only to the two parties, group calls to current group members).
im.calls.history()
Returns calls you took part in, page by page, newest first.
| Parameter | Type | Required | Description |
|---|---|---|---|
page.limit | number | No | Items per page, 20 by default, at most 50 |
page.cursor | string | No | The next_cursor from the previous page |
Returns: Promise<Page<CallSummary>>.
Errors: rate_limited (query_rate).
im.calls.groupCall()
Gets a group's ongoing group call, also updating the local group call banner.
| Parameter | Type | Required | Description |
|---|---|---|---|
group_id | string | Yes | Group ID |
Returns: Promise<CallHandle | null>, null when there's no ongoing group call.
Errors: not_found (the group doesn't exist), not_group_member, rate_limited.
im.calls.groupCallBanner()
Synchronously returns the locally recorded group call banner for this group.
| Parameter | Type | Required | Description |
|---|---|---|---|
group_id | string | Yes | Group ID |
Returns: GroupCallBanner | null. null means no ongoing group call, or the SDK doesn't know yet (after a page refresh, for groups not yet queried), see Group call banner.
call.getSnapshot()
Returns the call's current snapshot (read-only, replaced as a whole on change), see CallSnapshot.
Returns: CallSnapshot.
call.subscribe()
Subscribes to snapshot changes.
| Parameter | Type | Required | Description |
|---|---|---|---|
listener | () => void | Yes | Called when the snapshot changes |
Returns: () => void, which unsubscribes when called.
call.on()
Listens for media tracks and data messages.
| Parameter | Type | Required | Description |
|---|---|---|---|
event | 'track' | 'trackRemoved' | 'data' | Yes | Event name |
listener | (payload) => void | Yes | Handler |
Returns: () => void, which stops listening when called.
| Event | Payload | Description |
|---|---|---|
track | { username: string | null; media_uid: number; kind: 'audio' | 'video'; track: MediaStreamTrack; local: boolean } | A new track: someone joined or turned on their camera, or your own microphone or camera turned on |
trackRemoved | Same as above | A track is no longer available: someone left or turned off their camera |
data | { username: string | null; media_uid: number; data: Uint8Array } | A data message from another member |
Tracks only fire in the tab holding the media connection.
call.join()
Answers an incoming call or a group call invitation, joins a group call from the banner, or rejoins a group call after leaving. Must be called from a user click.
Returns: Promise<void>, resolving once answered and media is connected.
Errors: call_in_progress (in_other_call you're in another call, with it in details.call_id; joined_on_other_device you already answered on another device), call_ended (the call has ended), limit_exceeded (call_full the group call is full; call_member_limit the group call has involved the maximum number of members), version_conflict (your current state can't answer, such as a one-to-one call you already declined), permission_denied (rtc_disabled and others), not_group_member, group_muted, user_muted, invalid_state (media_in_other_tab), permission_required, device_error (in these two cases the call is already answered, see Microphone and camera), unsupported (media components failed to load), rate_limited (signal_rate; the SDK retries once automatically when the wait is at most 5 seconds).
call.reject()
Declines an incoming call or group call invitation.
| Parameter | Type | Required | Description |
|---|---|---|---|
p.reason | 'declined' | 'busy' | No | declined (default), or busy to decline as busy |
Returns: Promise<void>. Declining again counts as success.
Errors: version_conflict (you already answered, or weren't invited), rate_limited.
call.hangup()
Hangs up: cancels for the caller before connecting, leaves when in the call, and declines when ringing. This tab's media is disconnected whether or not it succeeds.
Returns: Promise<void>. Hanging up an ended call counts as success.
Errors: rate_limited, and network errors such as network_error.
call.end()
Ends the whole group call: all members leave and ringing invitations are canceled. Only the starter, group owner and group admins can; for one-to-one calls it's the same as hanging up.
Returns: Promise<void>.
Errors: permission_denied (not_host), rate_limited.
call.invite()
Invites more group members to a group call.
| Parameter | Type | Required | Description |
|---|---|---|---|
usernames | string[] | Yes | Group members to invite, 1 to 1000, not including yourself |
Returns: Promise<CallInviteResult[]>, each person's result, see Invite more people.
Errors: invalid_argument (group_call_only one-to-one calls can't invite; invalid_usernames; too_many_invitees), permission_denied (not_in_call you're not in the call; rtc_disabled), call_ended, app_unavailable, local_validation (usernames is empty).
call.resume()
After a page refresh, or after the tab holding the call is closed, resumes the call in this tab: reconnects media and turns on the microphone and camera. Only available when the snapshot's resumable is true, and must be called from a user click.
Returns: Promise<void>.
Errors: invalid_state (not_resumable), call_ended, version_conflict (you're no longer in the call), permission_required, device_error, unsupported.
call.setMicrophoneEnabled()
Turns the microphone on or off (mute).
| Parameter | Type | Required | Description |
|---|---|---|---|
on | boolean | Yes | true on, false off |
Returns: Promise<void>.
Errors: invalid_state (media_not_connected media isn't connected yet; media_in_other_tab the call is in another tab), permission_required, device_error.
call.setCameraEnabled()
Turns the camera on or off, for video calls only.
| Parameter | Type | Required | Description |
|---|---|---|---|
on | boolean | Yes | true on, false off |
Returns: Promise<void>.
Errors: invalid_state (audio_call voice calls can't turn on the camera; media_not_connected; media_in_other_tab), permission_required, device_error.
call.sendData()
Sends data to the other members in the call over the media connection.
| Parameter | Type | Required | Description |
|---|---|---|---|
data | Uint8Array | string | Yes | The data; strings are UTF-8 encoded |
Returns: Promise<void>.
Errors: invalid_state (media_not_connected).
call.attach()
Sets all of a member's current audio and video tracks as an element's srcObject. Call it again after the member's tracks change (track, trackRemoved events). Does nothing if the member isn't found.
| Parameter | Type | Required | Description |
|---|---|---|---|
username_or_uid | string | number | Yes | The member's username, or their media_uid in the call |
el | { srcObject: unknown } | Yes | A media element, usually <video> or <audio> |
Returns: nothing.
Events
Listen for these with im.on(), see Events.
| Event | Payload | Description |
|---|---|---|
call.incoming | { call: CallHandle; alert: boolean } | An incoming call starts ringing. The tab with alert true plays the ringtone |
call.changed | { call: CallHandle; change: CallChange | null } | The call changed (through others' actions or other devices), see Show call state |
groupCall.changed | GroupCallBanner | A group's call banner changed; read it with im.calls.groupCallBanner() |
Data structures
CallSnapshot
The handle's snapshot.
| Field | Type | Description |
|---|---|---|
call | Call | The call |
self | CallSelf | null | Your state in the call: state is your member state (null if not a member), joined_on_this_device says whether you're in the call on this device (this browser) |
inviter | object | The incoming call's inviter: username, nickname, avatar_url; only on incoming calls |
group | object | null | The group call's group: group_id, name, avatar_url; null for one-to-one; only on incoming calls |
media | string | This tab's media connection: none, connecting, connected, reconnecting, disconnected |
local | { microphone: boolean; camera: boolean } | Whether your microphone and camera are on |
remote | Array<{ username: string | null; media_uid: number; audio: boolean; video: boolean }> | Whether other members' microphones and cameras are on |
ringing | boolean | Whether it's ringing here; becomes false automatically at the ringing deadline |
media_tab | 'this' | 'other' | null | Whether media is in this tab, another tab of this browser, or neither |
resumable | boolean | Your call on this device has no tab holding the media and can be resumed with resume() |
Call
The call.
| Field | Type | Description |
|---|---|---|
call_id | string | Call ID |
type | 'single' | 'group' | One-to-one / group call |
group_id | string | null | The group call's group, null for one-to-one |
media | 'audio' | 'video' | Voice / video |
status | 'ringing' | 'active' | 'ended' | Waiting to connect / connected / ended, only moving forward in that order |
initiator | string | null | Starter's username |
max_participants | number | Maximum people in the call at once, fixed at start |
joined_count | number | People in the call right now |
members | CallMember[] | Members; absent in call history |
created_at | string | Start time |
answered_at | string | null | Connect time, null if never connected |
ended_at | string | null | End time |
end_reason | string | null | End reason, see Hang up and end |
ended_by | string | null | The user who ended the call; null when ended by the server, platform or system |
duration_seconds | number | Seconds from connecting to ending (to now if not ended), 0 if never connected |
version | number | Version, incremented on every change |
ext | Record<string, string> | null | Custom fields from the start |
All times are ISO 8601 strings.
CallMember
A member of the call.
| Field | Type | Description |
|---|---|---|
username | string | null | Username, null for deleted users |
role | 'initiator' | 'invitee' | 'joiner' | Starter / invited / joined a group call themselves |
state | string | ringing / joined in the call / left / declined / busy / missed / canceled invitation canceled |
media_uid | number | The member's number in this call, usable with attach() |
invited_by | string | null | Who last invited them |
ring_expires_at | string | null | Ringing deadline |
joined_at | string | null | Latest join time |
left_at | string | null | Latest leave time |
leave_reason | string | null | Latest leave reason, see Leave reasons |
CallSummary
An item from im.calls.history(): a Call (without members), plus:
| Field | Type | Description |
|---|---|---|
self | CallSelf | Your state in this call |
peer | string | null | The other user's username for one-to-one calls, null for group calls |
CallChange
The change in a call.changed event.
| Field | Type | Description |
|---|---|---|
kind | string | Change type, see Show call state |
usernames | Array<string | null> | Members whose state changed |
actor | string | null | Who did it; null for your server, the platform and the system |
reason | string | null | Decline, leave or end reason |
CallInviteResult
One person's result from invite(), one of:
| Field | Type | Description |
|---|---|---|
username | string | Username |
status | string | On success: invited, busy, already_in_call |
code | string | Error code on failure |
message | string | Explanation on failure |
details | Record<string, unknown> | Detailed reason on failure |
GroupCallBanner
A group call banner.
| Field | Type | Description |
|---|---|---|
group_id | string | Group ID |
call_id | string | Call ID |
media | 'audio' | 'video' | Voice / video |
status | 'ringing' | 'active' | 'ended' | Call status |
joined_count | number | People in the call, possibly briefly off |
initiator | string | null | Starter |
version | number | Version |
Standalone channels
The calling, ringing and answering on this page are only for one-to-one and group calls. For meetings and voice rooms that don't depend on an IM group, use standalone RTC channels: channels have no incoming calls or ringing. A user can only be in one call or channel at a time, and the two are billed separately.
