Calls API
Use the control WebSocket for call commands and events. Its winning owner can attach the separate raw PCM media socket. Rust crate architecture explains package responsibilities.
Routes and snapshot
All routes require an account-authorized calls bearer token in the
Authorization header, including WebSocket upgrades. URL-encode identifiers;
never put credentials in URLs.
| Method | Path | Response |
|---|---|---|
| GET | /v1/accounts/{account}/calls/current | JSON snapshot |
| WebSocket | /v1/accounts/{account}/calls/socket | JSON control messages |
| WebSocket | /v1/accounts/{account}/calls/{callId}/media | JSON setup and binary PCM |
curl -sS -H "Authorization: Bearer $TOKEN" \ "$BASE/v1/accounts/$ACCOUNT/calls/current"{ "capabilities": { "directAudio": true }, "availability": { "busy": false, "ownedByThisClient": false }, "currentOffer": null, "currentPendingOffer": null, "currentCall": null, "audioFormat": { "sampleRate": 16000, "channels": 1, "frameMs": 20 }}| Field | Meaning |
|---|---|
capabilities | Available flags only; missing flags mean unavailable; {} means no calling |
currentPendingOffer | Incoming candidate waiting for readiness or preparation; cannot be answered |
currentOffer | Actionable incoming offer; clears when claimed or ended |
currentCall | Active call metadata, including kind, callType, direction, state and reported participant controls |
audioFormat | Client/daemon PCM format, independent of the WeChat codec |
Same-account observers see call metadata without owner tokens or audio access.
HTTP ownership is always false: ownership belongs to a control socket. Global
busy also covers other accounts, whose call details stay hidden.
Client messages
Send these JSON text messages on the control socket. Every command has a
requestId; reuse it only for an identical command. Use the latest received
revision for answer/reject/cancel/hangup. Example revisions below are illustrative.
Readiness
Prepare your audio path, then advertise readiness. Connecting alone stays passive.
{ "type": "call.ready", "requestId": "ready-outgoing", "ready": true, "format": { "sampleRate": 16000, "channels": 1, "frameMs": 20 }, "scope": { "kind": "outgoing" }}Ready for one pending incoming call:
{ "type": "call.ready", "requestId": "ready-offer", "ready": true, "format": { "sampleRate": 16000, "channels": 1, "frameMs": 20 }, "scope": { "kind": "offer", "callId": "example-call", "expectedRevision": 1 }}Ready for incoming and outgoing calls, or withdraw readiness:
{ "type": "call.ready", "requestId": "ready-all", "ready": true, "format": { "sampleRate": 16000, "channels": 1, "frameMs": 20 }, "scope": { "kind": "all" }}{ "type": "call.ready", "requestId": "not-ready", "ready": false, "format": { "sampleRate": 16000, "channels": 1, "frameMs": 20 }}Omitting scope means all.
Start or answer
{ "type": "call.dial", "requestId": "dial-1", "target": { "kind": "direct", "contactId": "example-contact" }, "callType": "voice"}{ "type": "call.answer", "requestId": "answer-1", "callId": "example-call", "expectedRevision": 3}The initiating socket owns an outgoing call. For incoming calls, the first
successful answer wins; competing clients receive already_claimed.
Reject, cancel or hang up
Reject an incoming offer, cancel your outgoing call before pickup, or hang up after answering/connecting:
{ "type": "call.reject", "requestId": "reject-1", "callId": "example-call", "expectedRevision": 3}{ "type": "call.cancel", "requestId": "cancel-1", "callId": "example-call", "expectedRevision": 4}{ "type": "call.hangup", "requestId": "hangup-1", "callId": "example-call", "expectedRevision": 5}Audio controls
Mute outgoing audio; send muted: false to unmute. Clear queued outgoing audio
when interrupting a response; see stream generations.
call.mute silences this client’s outgoing stream in the daemon. It does not
send a remote microphone-control request or change the peer’s microphone.
{ "type": "call.mute", "requestId": "mute-1", "callId": "example-call", "muted": true}{ "type": "call.clear_audio", "requestId": "clear-1", "callId": "example-call"}Server messages
These JSON text messages arrive on the control socket. Media attachment has
its own media.ready response.
Hello
The first event includes the connection’s clientId and a current snapshot:
{ "type": "hello", "protocolVersion": 1, "clientId": "example-client", "capabilities": { "directAudio": true }, "availability": { "busy": false, "ownedByThisClient": false }, "currentOffer": null, "currentPendingOffer": null, "currentCall": null, "audioFormat": { "sampleRate": 16000, "channels": 1, "frameMs": 20 }}Command results
Match requestId to your command. Readiness succeeds without claiming a call:
{ "type": "command.result", "requestId": "ready-outgoing", "status": "accepted", "callId": null, "ownerToken": null, "code": null}A successful claim includes a secret token for media attachment:
{ "type": "command.result", "requestId": "dial-1", "status": "accepted", "callId": "example-call", "ownerToken": "<secret owner token>", "code": null}Acceptance starts the operation; it does not mean connected. An identical request
can return replayed. A rejected command returns error and a fixed code:
{ "type": "command.result", "requestId": "ready-outgoing", "status": "replayed", "callId": null, "ownerToken": null, "code": null}{ "type": "command.result", "requestId": "answer-1", "status": "error", "callId": null, "ownerToken": null, "code": "already_claimed"}Pending and actionable offers
A pending candidate has phase waiting_for_ready or preparing:
{ "type": "call.pending", "offer": { "callId": "example-call", "revision": 1, "kind": "direct", "callType": "voice", "callerId": "example-contact", "phase": "waiting_for_ready" }}{ "type": "call.pending", "offer": null}An actionable offer can be answered or rejected using its revision:
{ "type": "call.offered", "offer": { "callId": "example-call", "revision": 3, "kind": "direct", "callType": "voice", "callerId": "example-contact" }}State and availability
{ "type": "call.state", "call": { "callId": "example-call", "revision": 5, "kind": "direct", "callType": "voice", "direction": "incoming", "state": "connected", "ownedByThisClient": true, "controls": [ { "participantId": "example-contact", "control": { "kind": "microphone", "muted": false } } ] }}{ "type": "call.state", "call": { "callId": "example-call", "revision": 6, "kind": "direct", "callType": "voice", "direction": "incoming", "state": "ending", "ownedByThisClient": true }}{ "type": "call.state", "call": { "callId": "example-call", "revision": 7, "kind": "direct", "callType": "voice", "direction": "incoming", "state": "ended", "ownedByThisClient": true }}{ "type": "calls.availability", "availability": { "busy": false, "ownedByThisClient": false }}States are offered, dialing, ringing, connecting, connected, ending,
ended and uncertain. Stop audio at ending; keep control connected while the
daemon finishes cleanup. uncertain reports an unconfirmed backend outcome;
its network receipts are retained even when the local session closes.
Participant control events
call.control reports a participant’s control state. The current control kind is
microphone; muted: true means muted, and false means unmuted. participantId
is the contact ID used by the direct-call target or incoming offer.
{ "type": "call.control", "callId": "example-call", "participantId": "example-contact", "control": { "kind": "microphone", "muted": true }}{ "type": "call.control", "callId": "example-call", "participantId": "example-contact", "control": { "kind": "microphone", "muted": false }}These events go to the owner and same-account observers. They need no reply;
clients can ignore them. Repeated unchanged microphone state produces no event.
The latest known values also appear in currentCall.controls in HTTP snapshots,
hello and call.state. An omitted controls means no state has been reported.
Control updates do not change the call-state revision or PCM format, end the call,
or interrupt audio in the other direction.
Audio generation and errors
After successful call.clear_audio, the send track advances:
{ "type": "track.updated", "callId": "example-call", "track": { "id": 1, "generation": 2, "direction": "send", "format": { "sampleRate": 16000, "channels": 1, "frameMs": 20 } }}A socket-level failure uses call.error:
{ "type": "call.error", "code": "forbidden"}Error codes are invalid_request, busy, already_claimed, offer_expired,
not_owner, unsupported, media_not_ready, outcome_unknown, capacity,
stale, forbidden and closed. Lost replies do not authorize automatic retry.