Audio and video calls
Audio and video calls cover one-to-one calls and multi-party calls in groups, carried by media services the platform deploys. For meetings and voice rooms that don't depend on an IM group, use standalone RTC channels.
| You want to | See |
|---|---|
| Turn on audio and video | Turn on the runtime policy rtc_enabled in the console (off by default), see Runtime policies |
| Understand the rules for calling, answering and hanging up | One-to-one calls, Group calls |
| Implement calls in a client | What clients need to do |
| Handle disconnections and app restarts | Disconnection and recovery |
| Show call results | End reasons, Call records |
| Query or end calls from your server, or remove people from calls | Call management |
| Sync call results to your business system | Event callbacks |
| Check usage and charges | Billable minutes and statistics |
Requirements: the app has the runtime policy rtc_enabled on, is in good standing, is within its concurrent call quota, and media services are available. Once new billing is enabled, the app also needs a positive standard audio/video duration balance; before switching to new billing, the original plan's switch applies. See calls on the "Audio/video" page of the app's details in the console, and actual charges under "Audio/video billing".
Basic rules
- Two kinds of calls: one-to-one calls (
single) between two users, and group calls (group) within a group, whose participants must be members of the group. Multi-party calls through these call APIs need an IM group; for meetings or voice rooms without a group, use standalone RTC channels. - Two kinds of media: audio calls (
audio) and video calls (video), chosen when starting and not switchable during the call. In a video call clients can turn off their camera, and the call is still recorded and billed as video. - One call per person at a time: a user can only be in one call at a time, and waiting for the callee to answer after starting a one-to-one call counts as being in a call; only one of their devices can be in the call. People in a call don't get new incoming calls: a one-to-one call to them is busy, and a group call invitation is recorded as busy without ringing. People who are only ringing and haven't answered can receive several incoming calls at once; after answering one, they must hang it up before answering another.
- One group call per group at a time: if a group already has an ongoing group call, starting another returns that call's ID, and the client joins it instead.
- The server is authoritative: call status, who's in the call, and when it connected and ended all follow the server's records. Every state change increments the call's
version, and notifications and responses clients receive carry the full call, so clients only need to apply ones with a higher version than they have locally. - Call ID: each call has a
call_id, which identifies it in queries, ending calls and event callbacks.
One-to-one calls
Start: the caller gives the callee's username and media type, optionally with custom fields
ext(such as a business order number), which reach the callee with the incoming call and also appear in the call object and event callbacks. Starting checks:- the app has audio and video on and can provide service;
- the caller isn't globally muted for one-to-one chat (
chat), otherwiseuser_mutedis returned. Muted users can still answer; - the same rules as sending one-to-one messages:
user_blockedif the callee has blocked the caller;not_friendif the app only allows messages between friends and they aren't friends; - the caller isn't in another call right now, otherwise
call_in_progressis returned; - the caller's call rate, and the app's concurrent call quota.
A callee who doesn't exist or was deleted returns
not_found. A banned callee doesn't affect starting: they get no incoming call, the call ends as "no answer" after ringing times out, and their ban isn't revealed.Ringing: all the callee's online devices get an incoming call notification, and offline devices or those with the app in the background get an incoming call push. The caller enters the call right after starting, connecting to the media service and waiting. The ringing time is set by the runtime policy
rtc_ring_timeout_seconds, 60 seconds by default.Answer: the callee answers on any device; that device enters the call, the call connects (
statusbecomesactive), and their other devices stop ringing and show "answered on another device".
A call ends in one of these ways; for end_reason see End reasons:
| Case | End reason |
|---|---|
| Either side hangs up after connecting; duration runs from connecting to hanging up | completed |
| The caller hangs up before connecting (cancels) | canceled |
| The callee declines | rejected |
The callee is in another call when the call starts: no ringing, the call ends immediately and is already ended in the start response; or the callee declines as "busy" (sent automatically by the SDK when the device is in another call, such as a carrier phone call) | busy |
| Ringing times out without the callee answering | no_answer |
| Either side's device is offline for more than 45 seconds, see Disconnection and recovery | connection_lost |
A callee who is only ringing (receiving someone else's incoming call) isn't busy: new incoming calls ring too, and they choose which to answer. When two people call each other almost simultaneously, the call that arrives first rings as usual, and the later caller finds the other side busy. A callee blocking the caller or removing them as a friend before answering doesn't affect the incoming call already sent; blocking during a call doesn't interrupt it either.
Group calls
- Start: a group member starts a group call in the group, choosing audio or video and the members to invite, at most the participant limit minus one; they can also invite nobody and wait for others to join.
- The starter must be able to speak in the group: the group isn't banned, they're a member, aren't muted in the group, and are the owner or an admin if the group is muted for everyone; they also can't be globally muted for group chat (
group). - Invitees must be able to join: they're members, aren't muted in the group, and are the owner or an admin if the group is muted for everyone. Those who don't qualify aren't invited and are explained one by one in the start result (
not_found,not_group_member,group_muted), so nobody rings and then can't answer. People in another call are recorded as busy and don't ring. - Group calls don't check personal blocklists, the same as group messages.
- If the group already has an ongoing group call, returns
409 already_existswith that call indetails.call_id, and the client joins it instead.
- The starter must be able to speak in the group: the group isn't banned, they're a member, aren't muted in the group, and are the owner or an admin if the group is muted for everyone; they also can't be globally muted for group chat (
- Group banner: when a group call starts, the number of people in it changes, or it ends, all online group members get a group call status notification, used to show a "3 people in a call" banner and a join entry in the group conversation. The notification only carries a count, which may be slightly off; members opening the group conversation can query the group's current call.
- Join: invitees answering an invitation and other group members joining from the banner are both joining. Joining has the same conditions as starting, plus the call can't be full: people in the call plus people ringing can't exceed the participant limit (your own ringing isn't counted twice), since ringing invitations reserve places for invitees. Members who declined, missed or left can join again.
- Invite: members in the call can invite other group members, who must likewise be able to join; members who declined, missed or left can be invited again and ring again.
- Decline and miss: invitees can decline, and ringing timeouts count as missed (same duration as one-to-one); neither affects others continuing the call.
- Leave: members in the call leave by hanging up, and can join again later.
- End: the starter, the group owner and group admins can end a group call; others get
permission_denied(not_host). Ending makes all members leave and cancels ringing invitations. It also ends automatically when:- the number of people in the call drops to 0:
completedif it ever connected,canceledif not;connection_lostif the last person left by going offline or having their device signed out; - only one person remains in the call with no ringing invitations for 60 seconds:
completedif it ever connected,no_answerif not. These 60 seconds let other members join from the banner, and the timer is canceled if someone joins or is invited; - the group is dismissed or banned:
group_unavailable.
- the number of people in the call drops to 0:
- Group changes:
- members who quit or are removed from the group (including being blocked or deleted) leave the call immediately (
removed_from_group) and are removed from the media room; their ringing invitations are canceled; - being muted during a call, or the group being muted for everyone: people already in the call aren't affected, but afterwards can't start, join (including rejoining after leaving) or invite, and can't be invited; invitations already ringing keep ringing but are refused when answered.
- members who quit or are removed from the group (including being blocked or deleted) leave the call immediately (
- Participant limit: the maximum number of people in the call at once is set by the plan (including the starter, no limit by default); when the runtime policy
max_group_call_participantsisn't 0, the smaller of the two applies, fixed when the call starts; later policy changes don't affect ongoing calls. A group call can involve at most 100 members in total (including those who left, declined or missed); after that no new people can be invited or join, while people who already took part can rejoin as usual.
Multiple devices
- Incoming calls reach all devices: incoming call notifications go to all the callee's online devices; offline devices or those with the app in the background get an incoming call push valid for the remaining ringing time, and pushes not delivered in time are dropped. A device connecting during ringing gets the incoming call notification once more. For push channels and do-not-disturb rules for incoming calls, see Offline push.
- Answer on one device: that device enters the call, and your other devices get "answered on another device" and stop ringing; incoming call notifications on devices that got the push are cleared silently (a few push channels don't support clearing, in which case the app clears them next time it opens).
- Decline on one device: a one-to-one call ends, or your invitation in a group call ends, and your other devices stop ringing.
- Only one device in the call: media credentials are only issued to the device in the call. Your other devices can see the call status and hang up, but can't enter the same call at the same time; answering returns
call_in_progress(joined_on_other_device). Moving a call to another of your devices isn't supported yet. - The caller's other devices: after the caller starts a call, their other online devices get the call status and can show "in a call" with a hang-up option.
- Answering repeatedly on the same device (such as network retries) gives the same result and isn't treated as another device.
Disconnection and recovery
- Heartbeat: devices in a call send a call heartbeat every 15 seconds. After 45 seconds without one, the server considers the member offline: a one-to-one call ends with
connection_lost; in a group call the member leaves (connection_lost) and can rejoin. If the app is force-closed or the device loses network, the other side finds out within about 45 seconds. - Network switching: switching between Wi-Fi and mobile data doesn't affect the call as long as heartbeats resume within 45 seconds; both sides hear at most a few seconds of interruption.
- Recovering after an app restart: if the app restarts within 45 seconds of being closed by the system, query your ongoing call; if you're still in it on this device (
self.joined_on_this_deviceistrue), renew the media credential and reconnect to the media service to continue. If it has timed out, a one-to-one call has ended, and a group call can be rejoined. - Syncing after reconnecting: after signing in or reconnecting the persistent connection, clients query their ongoing call and ringing incoming calls and reconcile with local state. Notifications may be lost, duplicated or out of order; the query results and version numbers are authoritative.
- Account and device changes: if the device in a call is signed out or its sign-in expires, a one-to-one call ends with
connection_lost, and in a group call the member leaves (session_revoked). If the account is banned or deleted, their ongoing one-to-one call ends (user_unavailable) and they leave group calls (user_unavailable); if the account is deleted, their ringing one-to-one incoming calls end (user_unavailable) and group call invitations are canceled. Incoming calls ringing for a banned user keep ringing until they time out. - Maximum duration: calls last at most 12 hours, then end automatically (
max_duration).
Media services and media credentials
- Audio and video data is carried by the media service this service provides. Each call has one media room, created when the first person connects; when the call ends the server closes the room, disconnecting any devices still connected.
- After starting, answering or joining successfully, the response carries a media credential (
media) that the client uses to connect to the media service. Media credentials are used by the SDK; your app's business code doesn't need to parse them.- They're only issued to the device in the call; a ringing callee doesn't get one and can't listen in before answering;
- They're valid for 15 minutes (
media.expires_at), and the device renews them before they expire; - They only grant entry to this call's room. Audio calls can only send microphone audio, video calls can send microphone and camera, and neither can share the screen; data messages can be sent and received, which clients can use to sync states such as "the other side muted".
- When a member leaves a group call (hanging up, being removed, going offline), the server also removes them from the media room, so they can't keep listening with the credential they hold.
- If the client SDK is too old to support the current media service, starting, answering and joining return
permission_denied(media_provider_unsupported); prompt the user to update the app.
What clients need to do
Wrapped by the SDK once released
What follows is the interaction between clients and the server, which the client SDK wraps once released, leaving the app to handle only the UI. If you need to integrate directly before the SDK is released, contact us for the persistent connection protocol.
Clients should send call operations over the persistent connection, and use the client APIs when the app was just woken by an incoming call push and the connection isn't up yet. Requests and responses are the same either way; on the persistent connection, a request's data is the client API's request body plus call_id:
| Operation | Persistent connection op | Client API | Description |
|---|---|---|---|
| Start a call | rtc.start | POST /client/v1/calls | One of to_user (one-to-one callee) or to_group (group ID); media is audio or video; for group calls, usernames are members to invite, up to 1000 per request; optional ext and client_call_id, see below |
| Answer, join | rtc.join | POST /client/v1/calls/{call_id}/join | Answer a one-to-one call, answer a group call invitation, join a group call from the banner, or rejoin after leaving |
| Decline | rtc.reject | POST /client/v1/calls/{call_id}/reject | Optional reason: declined (default) or busy |
| Hang up | rtc.hangup | POST /client/v1/calls/{call_id}/hangup | Cancels for a caller before connecting, leaves when in the call, and declines when ringing. Any of your devices can hang up |
| End a group call | rtc.end | POST /client/v1/calls/{call_id}/end | Only the starter, group owner and group admins; same as hanging up for one-to-one calls |
| Invite | rtc.invite | POST /client/v1/calls/{call_id}/invite | Group calls only; usernames are 1 to 1000 other members |
| Heartbeat | rtc.heartbeat | POST /client/v1/calls/{call_id}/heartbeat | Every 15 seconds in a call; the first heartbeat after connecting to the media service carries media_connected: true |
| Renew media credential | rtc.renew_token | POST /client/v1/calls/{call_id}/token | Only accepted from the device in the call |
| Get call details | rtc.get | GET /client/v1/calls/{call_id} | One-to-one calls are visible only to the two parties; group calls to current group members, while people who left the group only see ended calls they took part in |
| Get ongoing call and incoming calls | — | GET /client/v1/calls/active | current is your ongoing call, incoming the ringing incoming calls (at most 20). Call after signing in and reconnecting |
| Get call history | — | GET /client/v1/calls | Calls you took part in, including missed and declined ones, newest first; limit 20 by default, at most 50 |
| Get the group's current call | — | GET /client/v1/groups/{group_id}/call | Call when opening a group conversation to show the banner; call is null when there's no ongoing group call |
For example, starting a one-to-one video call over the persistent connection:
{
"op": "rtc.start",
"id": "7",
"data": {
"client_call_id": "c-0001",
"to_user": "bob",
"media": "video",
"ext": { "order_id": "8812" }
}
}ext: string key-value pairs, at most 16, keys 1 to 64 characters without control characters, at most 1 KB JSON-encoded. Not included in incoming call pushes.client_call_id: a deduplication ID for starting, 1 to 64 visible ASCII characters. Retry with the same ID after network errors and timeouts; the same starter and ID create only one call, and repeated submissions return the call created first (status200from the client API,201the first time).
Responses to start, answer and join carry call (the call, plus self), media (the media credential) and heartbeat_interval_seconds (15); when the callee is busy at start and the call ends immediately, there's no media. Answer and join also carry changed. Decline, hang up and end carry call and changed. changed being false means no state changed (such as answering again on the same device, declining again, or hanging up an ended call), which can be treated as success; group call start and invite also carry per-invitee results. self is your state in the call: state is your member state (null if you're not a member), and joined_on_this_device says whether you're in the call on this device. Calls and members clients receive don't include these server-only fields: ended_via, member_count, peak_joined, billable_minutes, invited_at, joined_platform, join_count, joined_ms, first_joined_at, media_joined_at, media_left_at.
The server pushes these notifications over the persistent connection:
| Notification | Sent to | Content |
|---|---|---|
rtc.call_invite | All devices of people who start ringing | call is the call; inviter has the inviter's username, nickname and avatar_url; group has the group's group_id, name and avatar_url for group calls, null for one-to-one |
rtc.call_changed | All devices of members ringing or in the call, and of members whose state changed this time | call is the call after the change; change describes it: kind the change type, usernames the members whose state changed, actor who did it (null for your server, the platform and the system), reason the decline, leave, cancel or end reason |
rtc.group_call_changed | All online group members | Sent when a group call starts, the number of people in it changes, or it ends; only group_id, call_id, media, status, joined_count, initiator and version, without the member list |
Values of change.kind: created (started, only sent to the starter's other devices), invited (new invitations in a group call), joined (someone answered or joined), answered_elsewhere (you answered on another device; stop ringing), declined (someone declined), missed (a group call invitation timed out), left (someone left or was removed), canceled (a ringing group call invitation was canceled, such as on removal from the group), ended (the call ended). Several changes caused by one operation are sent as one notification, with kind as the final result; for example ended when a decline ends a group call.
{
"type": "event",
"event": "rtc.group_call_changed",
"at": "2026-10-04T19:22:32.957Z",
"data": {
"group_id": "100311544667045888",
"call_id": "100311615936659456",
"media": "audio",
"status": "ringing",
"joined_count": 1,
"initiator": "alice",
"version": 1
}
}Clients need to:
- show the entry according to
rtc_enabledin the runtime config, but treat the server's actual result as authoritative: once new billing is enabled, insufficient balance is also refused, and the balance can change at any time, so the local switch being on doesn't guarantee starting a call will succeed; - generate a new
client_call_idfor each start, and reuse it when retrying; - call
GET /client/v1/calls/activeafter signing in and reconnecting, to resume this device's ongoing call and ring for incoming calls whose notifications were missed; treat calls present locally but missing from the result as ended; - deduplicate incoming call notifications, pushes and query results by
call_id, since they may arrive more than once; stop ringing atring_expires_atand wait for the server's end notification; - send a heartbeat every 15 seconds in a call, using the client API when the persistent connection is unavailable; when a heartbeat returns
call_ended, end the local call, and when it returnsversion_conflict, you're no longer in the call (for example removed, or left after going offline), so disconnect media; - renew the media credential before it expires;
- decline incoming calls with
busywhile the device is in another call, such as a carrier phone call; - render call record messages in conversations.
Client call operations are rate-limited per user; over the limit returns 429 rate_limited with details.reason naming which one, and you should wait for Retry-After. These requests also count toward the app's client requests per second:
| Operation | Limit | details.reason |
|---|---|---|
| Start calls (one-to-one and group combined) | 10 a minute and 200 a day per user | call_rate |
| Same caller calling the same callee | 20 an hour | pair_call_rate |
| Start group calls in the same group | 10 a minute per group | group_call_rate |
| Invite in group calls | 30 people a minute per user; people beyond that get rate_limited individually in the results while the rest are invited | invite_rate |
| Answer, join, decline, hang up, end | 60 a minute combined per user | signal_rate |
| Renew media credential | 10 a minute per user | token_rate |
| Queries (call details, ongoing call, call history, group's current call) and repeated start submissions | 120 a minute combined per user | query_rate |
Heartbeats only count toward the app's client requests, and a member's second heartbeat within 5 seconds is ignored while still returning success. For how limits are calculated, see How rate limiting is calculated.
Client call operations may return these errors, which the SDK turns into prompts by code and details:
| HTTP status | code | Description |
|---|---|---|
| 400 | invalid_argument | details.reason is invalid_target (not exactly one of to_user and to_group), invalid_media, self_call (calling yourself), invalid_ext, invalid_client_call_id, invalid_usernames (usernames over 31, including yourself, or empty when inviting), too_many_invitees (invitees over the participant limit minus one; details.max is how many can be invited), group_call_only (inviting others to a one-to-one call), invalid_reject_reason |
| 403 | permission_denied | details.reason is rtc_disabled (the app policy is off, the plan doesn't allow it before switching, or the balance is insufficient under new billing), rtc_suspended (the platform suspended this app's audio and video; details.suspend_reason is why), rtc_not_configured (audio/video service temporarily unavailable), not_host (ending a group call without being the starter, group owner or admin), not_in_call (inviting others without being in the call), not_joined_device (renewing the media credential from a device that isn't in the call), media_provider_unsupported (app version too old) |
| 403 | user_muted, user_blocked, not_friend, not_group_member, group_muted, group_disabled | When starting, joining or inviting, the same mute, blocklist, friend and group membership checks as sending messages failed |
| 403 | app_unavailable | In read-only state (such as arrears), the app can't start calls or invite; other operations work as usual |
| 404 | not_found | The callee doesn't exist or was deleted; the call doesn't exist or isn't visible to you; the group doesn't exist |
| 409 | call_in_progress | You're already in a call and can't start, answer or join: details.reason is in_other_call (you're in another call, with it in details.call_id) or joined_on_other_device (you already answered or joined this call on another device) |
| 409 | call_ended | The call has ended and can't be answered, joined, invited to, renewed or heartbeated; details.end_reason and details.ended_at give the end reason and time |
| 409 | already_exists | The group already has an ongoing group call, in details.call_id; join it instead |
| 409 | version_conflict | Your state in the call doesn't allow this operation, such as declining after answering or declining without ever being invited: details.reason is member_state, and details.member_state is your current member state (null if never invited) |
| 409 | limit_exceeded | details.reason is call_full (the group call is full), call_member_limit (the group call has involved 100 members) or app_concurrent_calls (the app's ongoing calls reached the quota, with the quota in details.limit) |
Call states
Call status
A call's status only moves forward in this order:
status | Description |
|---|---|
ringing | Started, not yet connected |
active | Connected: two people were in the call at the same time for the first time |
ended | Ended, with the reason in end_reason |
Member states
Each member of a call has a role role and a state state. Roles: initiator the starter, invitee someone invited, joiner someone who joined a group call without being invited.
state | Description |
|---|---|
ringing | Ringing |
joined | In the call |
left | Left, with the reason in leave_reason. Can rejoin group calls |
declined | Declined the incoming call or invitation |
busy | Busy: was in another call when invited, without ringing; or declined as busy |
missed | Ringing timed out without answering |
canceled | The invitation was canceled while ringing, such as when the call ended or the member was removed |
Leave reasons
leave_reason when a member leaves a call:
leave_reason | Description |
|---|---|
hangup | Hung up themselves |
ended | Was still in the call when it ended |
connection_lost | Heartbeat timed out; the device went offline |
removed | Removed from the call by your server, the console or the platform |
removed_from_group | Quit or was removed from the group |
session_revoked | The device in the call was signed out or its sign-in expired |
user_unavailable | The account was banned or deleted |
app_unavailable | The app or tenant can't provide service, or the platform suspended this app's audio and video |
End reasons
end_reason when a call ends, shared by one-to-one and group calls. The app decides the text shown; the prompts below are only suggestions:
end_reason | When | Prompt for the starter | Prompt for the callee |
|---|---|---|---|
completed | Hung up after connecting; in a group call, the last person left, the starter, owner or an admin ended it, or only one person remained for over 60 seconds | Call duration | Call duration |
canceled | The starter hung up before connecting; in a group call, everyone in the call left before connecting, or it was ended before connecting | Canceled | Caller canceled |
rejected | The one-to-one callee declined | Declined | You declined |
busy | The one-to-one callee was busy, or declined as busy | Busy | Missed call |
no_answer | One-to-one ringing timed out; a group call never connected and only the starter remained for over 60 seconds | No answer | Missed call |
connection_lost | In one-to-one, either side went offline or was signed out; in a group call, the last person in the call went offline or was signed out | Call interrupted | Call interrupted |
server_ended | Your server or the console ended the call, or removed one side of a one-to-one call | Call ended | Call ended |
platform_ended | The platform ended the call | Call ended | Call ended |
user_unavailable | In a one-to-one call, one side's account was banned or deleted, or a ringing callee's account was deleted | Call ended | Call ended |
group_unavailable | The group was dismissed or banned | Call ended | Call ended |
app_unavailable | The app or tenant can't provide service, or the platform suspended this app's audio and video | Call ended | Call ended |
service_disabled | The platform disabled the media service carrying this call and required ongoing calls to end immediately | Call ended | Call ended |
max_duration | The call exceeded 12 hours | Call ended | Call ended |
New end reasons may be added; show unrecognized values as "Call ended".
A call's ended_via says who ended it: client a user in a client (with that user in ended_by), openapi your server, console the console, platform the platform, system the system (such as ringing timeouts, going offline, or busy determined by the server).
Call records
After a call ends, a call record message is written to the conversation automatically, exactly one per call:
- One-to-one: written to the two people's one-to-one conversation, with the caller as sender. Calls that never connected (such as missed, busy, canceled) count toward the callee's unread count so they see the missed call; connected calls and calls the callee actively declined (
rejected) don't. Not written if either account was deleted by then; - Group calls: written to the group conversation, with the starter as sender (the system if they're no longer in the group), not counted as unread. Not written if the group was dismissed;
- Call records don't send offline pushes: incoming, missed and canceled calls were already pushed;
- Apps can turn off call records in the runtime policies (
rtc_call_record_enabled), for example when the business system shows calls itself.
Call records have message type type call, which neither clients nor your server can send. Fields of body:
| Field | Type | Description |
|---|---|---|
call_id | String | Call ID, usable to get call details |
call_type | String | single one-to-one / group group call |
media | String | audio / video |
result | String | End reason, values as in End reasons |
duration_seconds | Number | Call duration in seconds, 0 if never connected |
started_at | String | Start time |
A call record in a group conversation (as returned by message history):
{
"conversation_id": "100311544667045888",
"conversation_type": "group",
"seq": 5,
"message_id": "100313815656169472",
"client_msg_id": "sys_rtc_100313666666102784",
"sender": "alice",
"sender_type": "user",
"recipient": null,
"type": "call",
"body": {
"call_id": "100313666666102784",
"call_type": "group",
"media": "video",
"result": "completed",
"duration_seconds": 32,
"started_at": "2026-10-04T19:30:41.871Z"
},
"ext": null,
"mentions": null,
"reply_to": null,
"need_receipt": false,
"exclude_from_unread": true,
"reactions": null,
"pinned": null,
"edited": null,
"recalled": null,
"erased": false,
"via": "system",
"created_at": "2026-10-04T19:31:17.393Z",
"message_version": 0
}Call records are stored as ordinary messages, kept according to the runtime policy message_retention_days, independent of how long call data is kept.
Billable minutes and statistics
This section explains how fields such as duration_seconds and billable_minutes in the APIs are counted. These fields aren't the basis for new billing charges: new billing computes standard duration graded by the resolution actually received, with an app's calls and channels totaled per month and then rounded, see Audio/video billing.
| Field | How it's counted |
|---|---|
duration_seconds | Time from connecting to ending; ringing doesn't count |
billable_minutes | Historical reporting field: each member's time from connecting or joining to leaving, rounded up to minutes individually, then summed. For example two people talking 28 seconds each is 2 minutes |
| Daily audio and video minutes | Counted on the day the call ended, used for reports, historical invoices and troubleshooting |
Don't estimate new billing charges as billable_minutes × new unit price; new billing also never charges these minutes again on top of standard duration.
For example, an audio-only call with two people talking 28 seconds each, with no other usage this month: reports show 2 minutes; new billing totals 56 standard seconds and charges 1 standard minute on the monthly total. Video calls also apply the coefficient of the tier actually received first. For attribution across days and months and corrections, see Rounding rules.
Concurrent call quota
The number of calls the app has going at once (started and not yet ended) is limited by the plan's concurrent call quota max_concurrent_calls, where 0 means unlimited; see it under "Plan and usage" for the app in the console, and Plans and billing. Starting a call after reaching the quota returns 409 limit_exceeded with details.reason app_concurrent_calls and the quota in details.limit:
{
"code": "limit_exceeded",
"message": "应用进行中的通话数已达上限",
"details": {
"limit": 1,
"reason": "app_concurrent_calls"
},
"request_id": "FW43EI3USADPEKRAFTOZWODLDX"
}The quota is only checked when starting, so ongoing calls aren't affected; several calls started at the same moment may push the actual count slightly over. For each day's peak of simultaneous calls, see Get call statistics.
Runtime policies
Audio/video runtime policies:
| Field | Description | Default | Range |
|---|---|---|---|
rtc_enabled | Whether audio and video calls are on | false | true, false |
rtc_ring_timeout_seconds | Ringing time for incoming calls in seconds, same for one-to-one and group calls | 60 | 20 to 120 |
max_group_call_participants | Maximum people in a group call at once, including the starter | 0 (no limit; the plan decides) | 0 or at least 3 |
rtc_call_record_enabled | Whether a call record is written to the conversation after a call ends | true | true, false |
- With
rtc_enabledoff, nobody can start, answer, join or invite, andpermission_denied(rtc_disabled) is returned. Once new billing is enabled, insufficient balance is refused the same way, but ongoing calls aren't forcibly ended when the balance runs out, and later usage is still settled. Before switching to new billing, it's also refused when the plan doesn't allow audio and video. - Ringing time and the participant limit are fixed when starting or inviting; changes only affect later incoming calls and calls.
Effects of app status
| Situation | Effect |
|---|---|
| The platform suspended this app's audio and video | Ongoing calls end immediately (app_unavailable); nobody can start, answer, join or invite, with permission_denied (rtc_suspended) returned and the platform's reason in details.suspend_reason. The console's "Audio/video" page shows why and when; contact us if you disagree |
| Audio/video service temporarily unavailable | Calls can't be started; returns permission_denied (rtc_not_configured) |
| The app is disabled or suspended, or the tenant is suspended or in the account-deletion cooling-off period | Ongoing calls end (app_unavailable), and users' requests return app_unavailable or tenant_unavailable, see App and tenant status |
| The app is read-only (such as in arrears) | Calls can't be started and group call invitations can't be sent, returning app_unavailable (details.reason arrears when in arrears); answering, joining, declining, hanging up, heartbeats and renewals work as usual, so calls already started can finish normally. Your server can still query and end calls and remove members |
| The app is deleted or the tenant closes its account | Ongoing calls end (app_unavailable), then call data is deleted |
Event callbacks
With event callbacks configured in the console, your server can receive these audio/video events. Events identify users by username, carry the ext from the start, and contain no information about the media service carrying the call:
| Event | When |
|---|---|
rtc.call_created | A call is started, including calls that end immediately because the callee is busy |
rtc.call_answered | The call connects |
rtc.member_joined | Someone joins a group call (not sent for the starter joining at start) |
rtc.member_left | Someone leaves a group call (not sent for leaving when the call ends) |
rtc.call_ended | The call ends, with the end reason, duration, billable minutes and each member's time in the call |
Each call has exactly one rtc.call_created and one rtc.call_ended. Events may be delivered more than once and in any order; record call results from rtc.call_ended, for example charging your users by the call's billable minutes, with the order number in ext to match your business.
Data retention
- Calls and members are deleted 180 days after ending, after which call queries no longer return them;
- Call statistics are kept until the app is deleted;
- Call record messages in conversations are kept according to
message_retention_days.
Data structures
Call object
The call returned by Get call details, End a call and other APIs. Each item from List calls has no members.
| Field | Type | Description |
|---|---|---|
call_id | String | Call ID |
type | String | single one-to-one / group group call |
group_id | String | Group ID for group calls, null for one-to-one |
media | String | audio / video |
status | String | Call status: ringing / active / ended, see Call status |
initiator | String | Starter's username, null for deleted users |
max_participants | Number | Maximum people in the call at once: 2 for one-to-one, max_group_call_participants at start for group calls |
joined_count | Number | People in the call right now, 0 after it ends |
ext | Object | Custom fields given at start, string key-value pairs, null if none |
created_at | String | Start time |
answered_at | String | Connect time, null if never connected |
ended_at | String | End time, null if not ended |
end_reason | String | End reason, see End reasons, null if not ended |
ended_by | String | The user who ended the call in a client, such as the person who hung up, declined or ended the group call; null when ended by your server, the platform or the system, and for deleted users |
duration_seconds | Number | Call duration: seconds from connecting to ending (to now if not ended), rounded down; 0 if never connected |
version | Number | Version, incremented on every change to the call's or members' state |
members | Array<Object> | All members, including those who left, declined or missed, see Call member object; sorted by media_uid ascending, at most 100 |
ended_via | String | Who ended it: client, openapi, console, platform, system, see End reasons; null if not ended |
member_count | Number | Members the call involved, including those who left, declined or missed |
peak_joined | Number | Maximum number of people in the call at once |
billable_minutes | Number | Historical statistics minutes, rounded per member when the call ends; 0 if not ended. Not the standard minutes actually charged under new billing, see the explanation |
An ended group video call:
{
"call_id": "100313666666102784",
"type": "group",
"group_id": "100311544667045888",
"media": "video",
"status": "ended",
"initiator": "alice",
"max_participants": 16,
"joined_count": 0,
"ext": {
"topic": "周会"
},
"created_at": "2026-10-04T19:30:41.871Z",
"answered_at": "2026-10-04T19:30:44.923Z",
"ended_at": "2026-10-04T19:31:17.049Z",
"end_reason": "completed",
"ended_by": "alice",
"duration_seconds": 32,
"version": 5,
"members": [
{
"username": "alice",
"role": "initiator",
"state": "left",
"media_uid": 1,
"invited_by": null,
"ring_expires_at": null,
"joined_at": "2026-10-04T19:30:41.871Z",
"left_at": "2026-10-04T19:31:17.049Z",
"leave_reason": "ended",
"invited_at": null,
"joined_platform": "ios",
"join_count": 1,
"joined_ms": 32126,
"first_joined_at": "2026-10-04T19:30:41.871Z",
"media_joined_at": "2026-10-04T19:30:41.898Z",
"media_left_at": null
},
{
"username": "bob",
"role": "invitee",
"state": "left",
"media_uid": 2,
"invited_by": "alice",
"ring_expires_at": "2026-10-04T19:31:41.871Z",
"joined_at": "2026-10-04T19:30:44.923Z",
"left_at": "2026-10-04T19:31:17.049Z",
"leave_reason": "ended",
"invited_at": "2026-10-04T19:30:41.871Z",
"joined_platform": "android",
"join_count": 1,
"joined_ms": 32126,
"first_joined_at": "2026-10-04T19:30:44.923Z",
"media_joined_at": "2026-10-04T19:30:44.947Z",
"media_left_at": null
},
{
"username": "carol",
"role": "invitee",
"state": "left",
"media_uid": 3,
"invited_by": "alice",
"ring_expires_at": "2026-10-04T19:31:41.871Z",
"joined_at": "2026-10-04T19:30:46.967Z",
"left_at": "2026-10-04T19:31:02.014Z",
"leave_reason": "hangup",
"invited_at": "2026-10-04T19:30:41.871Z",
"joined_platform": "web",
"join_count": 1,
"joined_ms": 15047,
"first_joined_at": "2026-10-04T19:30:46.967Z",
"media_joined_at": "2026-10-04T19:30:46.983Z",
"media_left_at": null
}
],
"ended_via": "client",
"member_count": 3,
"peak_joined": 3,
"billable_minutes": 3
}Call member object
| Field | Type | Description |
|---|---|---|
username | String | Username, null for deleted users |
role | String | Role: initiator starter / invitee invited / joiner joined a group call themselves |
state | String | Member state, see Member states |
media_uid | Number | The member's number in this call, from 1, assigned in the order people became members |
invited_by | String | Username of whoever last invited them; null for the starter and joiners |
ring_expires_at | String | Ringing deadline of the latest invitation, null if never invited |
joined_at | String | Latest join time, null if never joined |
left_at | String | Latest leave time, null if never left |
leave_reason | String | Latest leave reason, see Leave reasons, null if never left |
invited_at | String | Latest invitation time, null if never invited |
joined_platform | String | Platform of the device used for the latest join, such as ios, android, web, with values as platform at sign-in; null if never joined |
join_count | Number | Times joined; members can rejoin group calls after leaving |
joined_ms | Number | Total time in the call in milliseconds, counting only after connecting. For members still in the call, the current stint is added when they leave |
first_joined_at | String | First join time, null if never joined |
media_joined_at | String | When they first connected to the media service, null if never. If it stays null after answering, the device's network usually can't reach the media service |
media_left_at | String | When they last disconnected from the media service, cleared on reconnecting, null if none |
Quota shared with standalone channels
Calls and standalone RTC channels share two limits:
- A user can only be in one call or channel at a time. While a user is in a channel, or has just left one and its media is still being cleaned up, they can't start or answer calls;
- The app's concurrent call quota counts calls and channels together.
Call billing rounding isn't affected; channels use separate meter items rounded on daily totals, see Channel usage and corrections.
