Live call control
Live control lets you mutate a Voice App call while it’s in progress (hang it up, mute a leg, send DTMF, or play audio into it) via authenticated REST. Every operation is asynchronous and idempotent; the outcome is reflected in the call/leg state you can read back from the inspection API.
Contract shared by every control endpoint
Section titled “Contract shared by every control endpoint”All four operations follow the same shape:
- URL pattern:
POST /api/v1/voice/calls/{call_id}/{action}wherecall_idiscs_<ULID>andactionis one ofhangup,mute,dtmf,play. - Response:
202 Acceptedwith{ "request_id": "req_..." }. The work is queued and applied to the live call. Idempotency-Keyheader: required on every request. See Idempotency below.- Live-call requirement: the call must be in
initiated,ringing, oransweredstate. Otherwise:409 call_not_live.
Hangup
Section titled “Hangup”Hang up a specific leg or the whole call.
POST /api/v1/voice/calls/{call_id}/hangupContent-Type: application/jsonIdempotency-Key: <unique-per-attempt>
{ "leg_id": "cl_...", // optional; omit to hang up the whole call "reason": "agent_done" // optional; free-form label surfaced on the call}Mute or unmute a specific leg. Muting stops audio from the leg but keeps the call connected.
POST /api/v1/voice/calls/{call_id}/muteContent-Type: application/jsonIdempotency-Key: <unique-per-attempt>
{ "leg_id": "cl_...", // required "on": true // true = mute; false = unmute}Send DTMF digits into the call as if the platform pressed them (useful for punching through IVRs).
POST /api/v1/voice/calls/{call_id}/dtmfContent-Type: application/jsonIdempotency-Key: <unique-per-attempt>
{ "leg_id": "cl_...", // optional; omit to send on primary leg "digits": "1234#", // required; matches ^[0-9*#A-D]+$ "duration_ms": 100 // optional; 40..1000, default 100}Play an audio file into the call. Returns immediately with a
playback_id; playback ends naturally or when interrupted by DTMF.
POST /api/v1/voice/calls/{call_id}/playContent-Type: application/jsonIdempotency-Key: <unique-per-attempt>
{ "leg_id": "cl_...", // optional; omit to play on primary leg "media_url": "https://.../audio.mp3", // required; HTTPS MP3 or WAV "loop": 1, // optional; 1..10, default 1 "on_dtmf": "stop" // optional; "stop" | "ignore", default "stop"}Response:
{ "request_id": "req_...", "playback_id": "pb_..."}Idempotency
Section titled “Idempotency”Every control request must include an Idempotency-Key header (up
to 255 chars). This makes the API safe to retry.
Semantics:
- Same key, same body → server returns the cached response from the first attempt. No new work is queued.
- Same key, different body →
422 idempotency_key_collision. - Same key, still in flight →
409 idempotency_replay_in_progress. Wait and retry. - Keys are scoped per account. Cached responses live for 24 hours. In-flight locks expire after 5 minutes.
Recommended: use a UUID per logical intent (not per HTTP retry). If your code decides “hang up this call because the agent finished,” mint one key at that decision point and reuse it on every network retry.
See Retries & idempotency for the general contract.
Response shapes
Section titled “Response shapes”202 Accepted (all endpoints)
Section titled “202 Accepted (all endpoints)”{ "request_id": "req_..." }play additionally returns playback_id.
{ "type": "invalid_state", "code": "call_not_live", "message": "Call is in state 'completed'; live control requires initiated/ringing/answered."}Error catalog
Section titled “Error catalog”| HTTP | code | When |
|---|---|---|
| 400 | idempotency_key_required | The header wasn’t sent. |
| 400 | idempotency_key_too_long | Header value exceeds 255 chars. |
| 404 | none | Call ID doesn’t exist or isn’t owned by your account. |
| 409 | call_not_controllable | Call has no Voice App (SIP Trunk calls don’t support runtime mutations). |
| 409 | unsupported_api_version | Owning Voice App is pinned to 2025-08-01. Bump the pin to 2026-06-01. |
| 409 | call_not_live | Call state is not one of initiated / ringing / answered. |
| 409 | leg_not_found | The leg_id you passed isn’t a leg of this call. |
| 409 | idempotency_replay_in_progress | Same key is currently being processed. |
| 410 | call_lost_owner | Call ended or was migrated to another platform instance. |
| 422 | idempotency_key_collision | Same key, different request body. |
| 503 | call_platform_unavailable | Platform is temporarily unreachable. Retry with backoff. |
| 503 | idempotency_unavailable | Idempotency store is temporarily unavailable. Retry with backoff. |
| 503 | wrong_instance | The call is served by another platform instance. Retry with backoff. |
Ordering
Section titled “Ordering”Two control requests against the same call are processed in the order
they arrive at the platform. If you send mute then hangup back-to-back,
mute will complete first (even if just barely). If you need to
serialize on your side, confirm the effect via the
inspection API before firing the next
mutation.
Requests against different calls have no ordering guarantee.