VoiceBlender API Reference
July 27, 2026 · View on GitHub
Base URL: http://localhost:8080/v1
All responses are Content-Type: application/json.
Asynchronous endpoints
Every endpoint that triggers a SIP request or response (e.g. INVITE, BYE, re-INVITE for hold/unhold, REFER for transfer, 100/180/183/200 for inbound calls) is asynchronous. The HTTP handler validates inputs synchronously (returning 4xx if anything fails up front) then queues the SIP work on a goroutine and returns 202 Accepted with a progressive-form status string (e.g. holding, unholding, hanging_up, early_media, ringing, answering).
The actual outcome of the SIP-level work is observed via webhook/WebSocket events:
| Event | When |
|---|---|
leg.connected, leg.early_media, leg.hold, leg.unhold, leg.disconnected, leg.transfer_* | Successful completion |
leg.command_failed | The SIP-level work failed after the HTTP 202 was returned. Payload: {leg_id, command, error} where command is one of ring, early_media, hold, unhold, add_to_room, etc. |
GET endpoints, in-memory state-change endpoints (/mute, /deaf, /dtmf/accept, /dtmf/reject), audio-pipeline endpoints (/play, /record, /tts, /stt, /agent/*), /dtmf (sends RTP, not SIP), and room CRUD remain synchronous.
Legs
A leg represents one side of a voice call — a SIP dialog, a WebRTC peer connection, a WhatsApp call, or a WebSocket session.
Leg Object
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "sip_inbound",
"state": "connected",
"room_id": "room-123",
"muted": false,
"deaf": false,
"held": false,
"role": "agent",
"sip_headers": {
"X-Correlation-ID": "abc-123"
},
"headers": {
"X-Correlation-ID": "abc-123"
}
}
| Field | Type | Values |
|---|---|---|
id | string | UUID |
type | string | sip_inbound, sip_outbound, webrtc, whatsapp_in, whatsapp_out, websocket_in, websocket_out, moq_in, livekit_publish, livekit_participant |
state | string | ringing, early_media, connected, held, hung_up |
room_id | string | Room ID if assigned, empty otherwise |
muted | boolean | true if the leg is muted (cannot be heard by others) |
deaf | boolean | true if the leg is deaf (cannot hear others) |
held | boolean | true if the call is on hold (SIP legs only) |
role | string | Routing role used by the room's audio routing matrix (e.g. "customer", "agent", "supervisor"). Omitted/empty means full mesh. |
sip_headers | object | Deprecated — X-* headers from the inbound INVITE. Only present on sip_inbound legs. Use headers for new code. |
headers | object | Custom protocol headers exposed by the leg's transport — X-/P- headers from a SIP INVITE, WebSocket handshake, or supplied at outbound dial time. |
POST /v1/legs
Originate an outbound SIP call.
Request:
{
"type": "sip",
"to": "sip:alice@192.168.1.100:5060",
"from": "+15551234567",
"privacy": "id",
"ring_timeout": 30,
"max_duration": 3600,
"codecs": ["PCMU", "PCMA", "G722", "opus"],
"headers": {
"X-Correlation-ID": "abc-123",
"X-Account-ID": "acct-456"
},
"auth": {
"username": "trunk-user",
"password": "trunk-pass"
},
"room_id": "room-123",
"amd": {
"initial_silence_timeout": 2500,
"greeting_duration": 1500,
"after_greeting_silence": 800,
"total_analysis_time": 5000,
"minimum_word_length": 100,
"beep_timeout": 10000
}
}
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | "sip", "whatsapp" (see WhatsApp Business Calling below), "websocket" (see WebSocket Legs), or "livekit_room" (see LiveKit Room Legs) |
to | string | yes | Destination. For sip legs, a SIP URI (e.g. "sip:alice@example.com"). For whatsapp legs, an E.164 phone number (with or without +). |
uri | string | no | Deprecated alias for to (sip legs only). Kept for backward compat; prefer to. |
from | string | no | Caller ID — sets the user part of the SIP From header (e.g. "+15551234567", "alice") |
privacy | string | no | SIP Privacy header value (e.g. "id", "none") |
ring_timeout | integer | no | Seconds to wait for answer; 0 = no timeout |
max_duration | integer | no | Maximum call duration in seconds after connect. The call is automatically hung up when reached. 0 or omitted = no limit. |
codecs | string[] | no | Codec preference order. Supported: PCMU, PCMA, G722, opus, AMR-WB, AMR-NB. Defaults to engine config. |
headers | object | no | Custom SIP headers to include in the outbound INVITE (e.g. X-Correlation-ID). Keys are header names, values are header values. |
auth | object | no for sip, yes for whatsapp | Digest auth credentials. Contains username (string, optional for whatsapp — defaults to from with + stripped) and password (string). For sip legs, retried on 401/407 challenge. |
room_id | string | no | Room ID to auto-add the leg to once media is ready. The leg joins the room on early_media (183+SDP) or connected (200 OK), whichever comes first. If the room does not exist, it is automatically created. |
webhook_url | string | no | Per-leg webhook URL. Events for this leg are routed exclusively to this URL instead of global webhooks. |
webhook_secret | string | no | HMAC-SHA256 signing secret for the per-leg webhook. |
amd | object | no | Enable Answering Machine Detection on this outbound call. Disabled by default — omit the field entirely to skip AMD. Include the object to enable; all inner fields are optional and default to sensible values when omitted or zero. See AMD Parameters below. |
speech_detection | bool | no | Emit speaking.started / speaking.stopped events for this leg. Omit to use the server default (SPEECH_DETECTION_ENABLED env var, default false). |
rtt | bool | no | Offer Real-Time Text (T.140 / RFC 4103) on the outbound INVITE. The peer may accept or ignore the m=text section; audio negotiation is unaffected either way. Default: false. |
AMD Parameters (all optional — "amd": {} enables AMD with all defaults):
| Field | Type | Default | Description |
|---|---|---|---|
initial_silence_timeout | integer | 2500 | Max milliseconds of silence before declaring no_speech. |
greeting_duration | integer | 1500 | Speech duration threshold (ms). Continuous/cumulative speech exceeding this value classifies the answerer as machine. |
after_greeting_silence | integer | 800 | Silence duration (ms) after initial speech to declare human. |
total_analysis_time | integer | 5000 | Hard analysis deadline (ms). If no determination is made within this window, the result is not_sure. When beep_timeout is non-zero and the media stream stalls, the terminal event can be delayed by up to beep_timeout beyond this deadline: a single timer covers both the analysis and beep windows. |
minimum_word_length | integer | 100 | Minimum speech burst duration (ms) to count as a word. Shorter bursts are treated as noise. |
beep_timeout | integer | 0 | After detecting machine, continue listening up to this many ms for the voicemail beep tone (800–1200 Hz). 0 = beep detection disabled. |
Constraints — total_analysis_time bounds every verdict: a threshold longer than the analysis window can never be reached, so that verdict never fires. Setting one threshold past the window is allowed, and is how you suppress a single verdict — a large greeting_duration, for example, disables machine while no_speech and human keep working. A window shorter than all of initial_silence_timeout, greeting_duration and after_greeting_silence is rejected with 400, because such a call could only ever end not_sure.
Analysis advances in 20 ms frames and the deadline is checked before a verdict is emitted, so a window exactly equal to a threshold does not reach it. Leave at least one frame of headroom above the thresholds you rely on.
Omitted or 0 values fall back to the defaults above (for beep_timeout, 0 means beep detection is disabled); a negative value on any field is rejected with 400.
Examples:
"amd": {} // all defaults
"amd": { "beep_timeout": 10000 } // defaults + beep detection
"amd": { "greeting_duration": 2000, "beep_timeout": 8000 } // custom thresholds
Jitter Buffer: The SIP ingress jitter buffer absorbs variation in RTP packet arrival times. When enabled, packets are reordered by sequence number and released to the decoder at a fixed 20 ms cadence; late packets that miss their slot are replaced with silence. The buffer adds latency equal to its target depth, so it is off by default — turn it on only when network jitter is a real concern (PSTN trunks, mobile carriers, satellite links), not for latency-sensitive voice-agent legs.
Configured server-wide:
SIP_JITTER_BUFFER_MS— target delay in ms, applied to every SIP leg.0= disabled passthrough (default).SIP_JITTER_BUFFER_MAX_MS— queue cap in ms (default300). Frames beyond this are dropped oldest-first to catch up after a stall.
WebRTC legs are unaffected — pion/webrtc provides its own jitter buffer.
Response: 201 Created — Leg object (initially in ringing state)
Early Media: When the remote sends a 183 Session Progress response with SDP, the leg automatically transitions to early_media state and a leg.early_media webhook event is emitted. The RTP media pipeline starts immediately, allowing the leg to be added to a room so other participants can hear the remote's early media (custom ringback, IVR prompts, etc.). When the remote answers (200 OK), the leg transitions to connected as normal.
Errors:
400— Invalid JSON, bad SIP URI, unknown codec, or unsupported type
WhatsApp Business Calling
VoiceBlender terminates calls to and from WhatsApp's SIP calling service. The signalling layer is SIP over TLS with HTTP Digest auth; the media layer is Opus over ICE + DTLS-SRTP (pion). Meta mandates both and does not support re-INVITE, so these operations return 409 for WhatsApp legs: hold, unhold, transfer.
Server prerequisites (see README env var table):
SIP_TLS_PORT=5061SIP_TLS_CERT/SIP_TLS_KEYpointing at a CA-signed certificate (Meta rejects self-signed) whose SAN matches the public FQDN you registered with Meta.- Operator-side: the SIP endpoint must be registered via Meta's Graph API (
POST /{phone-number-id}/settings). VoiceBlender does not perform this registration itself.
Outbound: POST /v1/legs (type=whatsapp)
Originate a call to a WhatsApp user.
Request:
{
"type": "whatsapp",
"to": "+15557654321",
"from": "+15551234567",
"auth": {
"password": "meta-issued-digest-password"
},
"room_id": "room-123",
"app_id": "myapp"
}
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | "whatsapp" |
to | string | yes | Destination phone number (E.164, with or without +). |
from | string | yes | Business phone number, E.164 (with or without +). Used as the From URI user-part and, by default, as the digest auth username. |
auth.password | string | yes | Meta-issued digest password for the business number. |
auth.username | string | no | Override the digest auth username. Defaults to from with + stripped, per Meta's spec. |
room_id | string | no | Room ID to auto-add the leg to once connected. Created on the fly if it doesn't exist. |
app_id | string | no | Application identifier for event stream filtering. |
The handler is asynchronous: it returns the leg view as soon as PCMedia setup succeeds and the leg is registered. ICE gathering, the INVITE round-trip (including the digest 401/407 retry), and the SDP answer apply happen in the background. Progress is signalled via webhook events:
leg.ringing(type: "whatsapp_out") — fires immediately after the leg is created. The HTTP response is sent at this moment.leg.connected— fires once Meta returns 200 OK and the SDP answer has been applied.leg.disconnected— fires if the INVITE fails (reason: "invite_failed"), the answer is rejected (bad_answer), or the dialog ends (remote_bye).
Response: 201 Created — Leg object in ringing state with type: "whatsapp_out". Subscribe to leg.connected / leg.disconnected (webhook or /v1/vsi) to track progress.
Errors (synchronous, before the leg is created):
400— missingto/from/password.503—SIP_TLS_PORTnot configured on this instance.500— local PCMedia or SDP setup failed.
Async errors (delivered via leg.disconnected event after 201):
invite_failed— Meta rejected the INVITE (e.g. 403 / 404 / digest auth failed) or the request timed out.bad_answer— Meta's 200 OK contained an SDP answer that pion couldn't apply.remote_bye— call ended normally or Meta hung up.
Inbound
INVITEs whose From-URI host is meta.vc (or any subdomain, e.g. wa.meta.vc) are routed to the WhatsApp handler automatically. The leg is created in ringing state with type: "whatsapp_in", a leg.ringing webhook event is emitted, and the call remains in this state until POST /v1/legs/{id}/answer is invoked. At that point a 200 OK with the pre-gathered SDP answer is sent and the leg transitions to connected.
The standard /answer, /mute, /deaf, /dtmf, /play, /record, /stt, /tts, and /agent/* endpoints all apply. The following explicitly return 409 Conflict:
POST /v1/legs/{id}/holdDELETE /v1/legs/{id}/holdPOST /v1/legs/{id}/transfer
WebSocket Legs
A websocket leg carries PCM audio over a single WebSocket connection. Both directions are supported:
- Outbound (
websocket_out) — VoiceBlender dials a remote WebSocket. Created viaPOST /v1/legswithtype: "websocket". - Inbound (
websocket_in) — an external client connects to VoiceBlender. Created by upgradingGET /v1/legs/websocket.
Both directions go straight to connected (no ringing/answer flow). Audio is signed 16-bit little-endian PCM, mono, at the configured sample rate (8000/16000/24000/48000 Hz — the room mixer resamples automatically). Hold and DTMF send are not supported on websocket legs. The bidirectional text-message channel is enabled with rtt: true (outbound) or ?rtt=true (inbound) and ties into the same /v1/legs/{id}/rtt REST endpoint and rtt.received event stream that SIP RTT uses.
Wire format
wire_format=binary (default): each WebSocket binary frame is one 20ms PCM frame at the configured sample rate. Most efficient; matches the framing used by Deepgram / VAPI-style providers.
wire_format=json_base64: PCM frames are wrapped as JSON text frames {"type":"audio","audio":"<base64-pcm>"}. Browser-friendly; matches the existing /v1/rooms/{id}/ws shape.
Text and control messages always use JSON text frames regardless of wire format:
{"type":"text","text":"hello"} // bidi text (rtt.received event + /rtt REST)
{"type":"ping","event_id":42} // server-initiated heartbeat
{"type":"pong","event_id":42} // reply to a server ping
{"type":"hangup"} // peer-initiated termination
Inbound text triggers a rtt.received event; outbound text is sent via POST /v1/legs/{id}/rtt or by the WebSocket peer writing the JSON frame above.
Outbound: POST /v1/legs (type=websocket)
{
"type": "websocket",
"url": "wss://agent.example.com/voice",
"sample_rate": 16000,
"wire_format": "binary",
"headers": {
"Authorization": "Bearer abc-123",
"X-Correlation-ID": "call-456"
},
"room_id": "room-789",
"rtt": true
}
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | "websocket" |
url | string | yes | ws:// or wss:// target URL. |
sample_rate | int | no | 8000/16000/24000/48000. Default 16000. |
wire_format | string | no | binary (default) or json_base64. |
sample_format | string | no | s16le (only option in v1). |
headers | object | no | Headers sent on the upgrade request (e.g. Authorization, X-*, P-*). |
room_id | string | no | Room to auto-add the leg to once connected. |
rtt | boolean | no | Enable bidi text channel. Default false. |
ring_timeout | int | no | Seconds to wait for the WS handshake to complete. Default unbounded. |
app_id, webhook_url, webhook_secret, max_duration, accept_dtmf, speech_detection | — | no | Same semantics as SIP legs. |
Response: 201 Created — Leg object in ringing state with type: "websocket_out". The dial completes asynchronously: leg.connected (success) or leg.disconnected (one of ring_timeout, service_unavailable, unauthorized, forbidden, not_found, ws_dial_failed).
Inbound: GET /v1/legs/websocket (HTTP upgrade)
GET /v1/legs/websocket?sample_rate=16000&wire_format=binary&room_id=room-789&rtt=true
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
X-Tenant: tenant-a
P-Asserted-Identity: alice@example.com
Query parameters: sample_rate, wire_format, sample_format, room_id, app_id, rtt, webhook_url, webhook_secret. X-* and P-* request headers (plus Authorization) are captured into the leg's headers map and exposed on leg.ringing (as sip_headers for back-compat) and in LegView.headers.
Both leg.ringing and leg.connected are emitted back-to-back on upgrade. leg.disconnected fires when the WS closes — reasons: hangup, timeout, connection_reset, peer_slow, ws_error.
MoQ Legs (experimental, PoC)
A MoQ leg (moq_in) carries bidirectional Opus audio over a single Media-over-QUIC session inside a WebTransport/HTTP/3 connection. Only the connection direction is fixed (client-initiated, hence moq_in); media flows both ways. The leg goes straight to connected (no ringing/answer flow), and event parity is limited to leg.connected / leg.disconnected — no DTMF, no RTT, no hold/transfer.
Reachable only on the HTTP/3 MoQ listener (MOQ_LISTEN_ADDR, default :8443), not on the regular HTTP/1.1 listener. Requires MOQ_ENABLED=true plus MOQ_TLS_CERT_FILE and MOQ_TLS_KEY_FILE. Speaks IETF draft-11 of moq-transport (via mengelbart/moqtransport); browser interop with draft-16 clients (moqtail, moq.dev) is not expected to work.
Wire format
One MoQ session per leg, with two fixed tracks:
- Downlink (server → client): server publishes namespace
mix, trackaudio— the room mix sent to the leg. - Uplink (client → server): server subscribes to namespace
mic, trackaudio— the leg's mic into the room.
Audio is Opus, one frame per MoQ Object (LOC-style), at 48 kHz mono with a 20 ms frame size. The encoder/decoder are hard-wired to 48 kHz — the sample_rate query param exists but only 48000 is accepted; the room mixer handles any rate conversion. The Opus bitrate is server-controlled via the MOQ_OPUS_BITRATE env var (default 24000 bps).
Inbound: CONNECT /v1/legs/moq (WebTransport extended-CONNECT)
CONNECT /v1/legs/moq?sample_rate=48000&room_id=room-789 HTTP/3
:protocol: webtransport
X-Tenant: tenant-a
P-Asserted-Identity: alice@example.com
Use a WebTransport-capable HTTP/3 client. Standard HTTP/1.1 clients (e.g. curl -X POST) cannot reach this endpoint.
| Query param | Type | Required | Description |
|---|---|---|---|
sample_rate | int | no | 48000 only (encoder/decoder are 48 kHz). Default 48000. |
room_id | string | no | Room to auto-add the leg to once connected. Created on demand if it does not exist. |
app_id | string | no | Tag the leg for event filtering. |
webhook_url | string | no | Per-leg webhook URL. |
webhook_secret | string | no | HMAC secret for per-leg webhook signing. |
X-* and P-* request headers (plus Authorization) are captured into the leg's headers map and surfaced on LegView.
Response:
200 OK— WebTransport extended-CONNECT accepted; MoQ session established. No JSON body — the response is the upgraded WebTransport session.400— Invalid query parameters or config.500— Room create failure.503— MoQ endpoint is not enabled (MOQ_ENABLED=false).
leg.connected fires on session establishment. leg.disconnected fires when the MoQ session closes — reasons: hangup, moq_error.
OpenAPI note: OpenAPI 3.1 does not define
connectas a path-item method, so this operation is documented inopenapi.yamlunderpostwith anx-actual-method: CONNECTextension. The actual wire method is HTTP/3 extended-CONNECT.
LiveKit Room Legs
VoiceBlender bridges SIP and LiveKit by joining an external LiveKit room as a participant, then mapping the LiveKit room's other participants onto VoiceBlender legs one-to-one. Each remote LK participant becomes its own livekit_participant leg in the same VoiceBlender room. The VB room mixer drives audio for everyone; there is no bespoke sum-mixer. No LiveKit SDK is used — the signaling protocol is spoken directly over WebSocket against pion's WebRTC stack.
Participant model (Model B). A POST /v1/legs type=livekit_room call creates one umbrella publish leg (type: "livekit_publish") that owns the outbound audio direction. As remote LK participants are discovered, each becomes a participant leg (type: "livekit_participant") registered in LegMgr and added to the publish leg's VoiceBlender room. Per-LK-participant operations — recording, role routing, mute/deaf, AI agents, STT, DELETE /v1/legs/{id} — all work natively because each LK participant is a real VB leg.
Why two leg types instead of one:
livekit_publishrepresents what VoiceBlender publishes to LiveKit (mixed audio of all non-LK participants in the same VB room). ItsAudioReaderis empty (no upstream of its own); itsAudioWriteris fed by the VB room mixer's mixed-minus-self output.livekit_participantrepresents one remote LK participant's audio coming from LiveKit. ItsAudioReaderyields decoded PCM from the LK participant's audio track; itsAudioWriteris a discard sink (the LK side already handles outbound mixing for that participant).
Audio feedback prevention. The publish leg's mixer whitelist (Hears) is automatically maintained to include every leg in its room except participant legs (role livekit_listen). This means LK participants' audio never gets re-published to LiveKit. The whitelist is recomputed on every leg.joined_room / leg.left_room event for the publish leg's room.
Server prerequisites:
LIVEKIT_ENABLED=trueLIVEKIT_URL=wss://your.livekit.server(overridable per-request)- (optional)
LIVEKIT_TOKEN_SIGNING_ENABLED=true+LIVEKIT_API_KEY+LIVEKIT_API_SECRETto let VoiceBlender mint JWTs. Security note: enabling minting puts a high-privilege secret (the LiveKit API secret can mint tokens for any room/identity) inside VoiceBlender. Default is OFF — callers pass a pre-signed JWT.
POST /v1/legs (type=livekit_room)
Two token modes, mutually exclusive per-request. If both are present, the explicit token wins.
Mode 1: caller-supplied JWT (default, no VB-side secrets):
{
"type": "livekit_room",
"livekit": {
"url": "wss://lk.example.com",
"token": "eyJhbGciOiJIUzI1NiIs..."
},
"room_id": "vb-room-7",
"webhook_url": "https://app.example.com/lk-hook"
}
Mode 2: VoiceBlender mints (requires LIVEKIT_TOKEN_SIGNING_ENABLED=true):
{
"type": "livekit_room",
"livekit": {
"room": "support-call-123",
"identity": "voiceblender-bridge",
"participant_name": "VoiceBlender",
"token_ttl": "30m",
"permissions": {
"can_publish": true,
"can_subscribe": true
}
},
"room_id": "vb-room-7"
}
Top-level fields: same room_id, webhook_url, webhook_secret, app_id, headers semantics as other leg types. app_id is inherited by every auto-created participant leg; webhook_url is set on the publish leg only.
livekit parameters:
| Field | Type | Required | Description |
|---|---|---|---|
url | string | no | LiveKit server endpoint (wss://...). Overrides LIVEKIT_URL. |
token | string | yes (mode 1) | Pre-signed LiveKit JWT. When set, all other minting fields are ignored. |
room | string | yes (mode 2) | LiveKit room name. Required when minting. |
identity | string | yes (mode 2) | LiveKit participant identity. Required when minting. |
participant_name | string | no | Display name surfaced in LK Room UIs. |
permissions | object | no | LK grant flags. See below. Defaults: publish=true, subscribe=true, data=false, admin=false. |
token_ttl | string | no | Go duration string (e.g. "30m", "6h"). Used only when minting. Defaults to LIVEKIT_DEFAULT_TOKEN_TTL (6h). |
opus_bitrate | int | no | Override LIVEKIT_OPUS_BITRATE for this leg. Must be 6000..510000. |
permissions fields (each optional; nil → default):
| Field | Default | Description |
|---|---|---|
can_publish | true | Publishing local audio. |
can_subscribe | true | Subscribing to remote tracks. |
can_publish_data | false | Data channel. Not used by the audio bridge. |
room_admin | false | Required for server-side admin actions on the LK room. |
Response: 201 Created — Leg object for the publish leg (type: "livekit_publish", state connected). Connect completes (or fails) before the HTTP response is sent. Use GET /v1/legs?type=livekit_participant or filter by room_id to enumerate the participant legs that get auto-created as LK participants join.
Headers surfaced in the publish leg's LegView.headers:
livekit_identity— participant identity reported byJoinResponselivekit_room— LK room name
Each livekit_participant leg's LegView.headers carries:
livekit_identity— the remote LK participant's identitylivekit_track_sid— the LiveKit track SID that backs this leg's audio
Lifecycle.
- The umbrella connection is created synchronously inside
createLiveKitRoomLeg. - As LK ParticipantUpdate + OnTrack events arrive, the API layer auto-creates a
livekit_participantleg per audio track, registers it inLegMgr, emitsleg.connected, and adds it to the publish leg's VB room (which firesleg.joined_room). - When a track is unpublished (or the LK participant disconnects), the matching participant leg is cleaned up;
leg.disconnectedfires with reasonlivekit_participant_left. - When the umbrella signaling closes (LK leave, server shutdown, network error), participant legs are torn down first, then the publish leg; the publish leg's
leg.disconnected.reasonmaps from the LiveKitDisconnectReason(e.g.livekit_kicked,livekit_server_shutdown,livekit_token_expired,livekit_room_deleted).
Mute / Deaf semantics (per leg).
POST /v1/legs/{publish_id}/mute— VB stops contributing to the LK publish track. LK participants stop hearing the rest of the VB room.POST /v1/legs/{participant_id}/mute— that LK participant's audio is excluded from the VB room mix. Other VB participants stop hearing them; the LK participant itself continues to receive audio normally.DELETE /v1/legs/{participant_id}— drops the VB-side leg only. The LK participant remains in the LiveKit room (VoiceBlender just stops representing them).DELETE /v1/legs/{publish_id}— tears down the entire umbrella: every participant leg is removed first, then the publish leg, then the LK signaling Leave is sent.
Mid-call JWT expiry. When VoiceBlender mints, default TTL is 6h; tune via token_ttl. When the caller supplies the JWT, the call ends when the JWT expires (no auto-refresh in v1). Long calls should use long-lived tokens.
Errors:
400— missinglivekitblock, missing token + signing disabled, missing room/identity in mint mode, invalidtoken_ttl, missing LiveKit URL.502— LiveKit signaling failed (bad token, server unreachable, protocol error). No events emitted; no leg registered.503—LIVEKIT_ENABLED=false.
GET /v1/legs
List all active legs.
Response: 200 OK — Array of Leg objects
GET /v1/legs/{id}
Get a single leg.
Response: 200 OK — Leg object
Errors: 404 — Leg not found
POST /v1/legs/{id}/ring
Asynchronous. Queue a SIP 180 Ringing provisional response (no SDP) on a ringing inbound SIP leg. Use this when SIP_AUTO_RINGING=false (the default) and you want to indicate alerting before deciding whether to early-media or answer.
The endpoint is idempotent in spirit — each call emits another 180 on the wire. Receivers tolerate re-sends, and SIP retransmission rules already handle reliability of provisionals, so multiple /ring calls are fine.
Auto-ringing default: Starting with this version, VoiceBlender does not send 180 Ringing automatically on inbound INVITE — only 100 Trying. The API caller drives ringing via
/ring,/early-media, or/answer. SetSIP_AUTO_RINGING=trueto restore the legacy "auto-180-on-INVITE" behavior.
Request: Empty body
Response: 202 Accepted
{ "status": "ringing" }
SIP-level send failures surface as leg.command_failed with command="ring".
Errors:
400— Not a SIP inbound leg404— Leg not found409— Leg is not inringingstate (already early-media, connected, or hung up)
POST /v1/legs/{id}/challenge
Asynchronous. Send a SIP 401 Unauthorized carrying a digest WWW-Authenticate challenge on a ringing (or early-media) inbound SIP leg, instead of answering or rejecting it. Use this to authenticate inbound INVITEs on a per-call basis — for example, challenge calls from unknown source addresses while accepting trusted peers without a challenge.
Inbound calls are not challenged by default; a VSI/REST client decides per call (the leg.ringing event carries source_address to inform that decision). Challenging is optional and additive — existing accept/reject behavior is unchanged.
Flow:
- You call
/challengeon a ringing leg. VoiceBlender sends401with a freshly generatednonceand tears the current leg down (aleg.disconnectedwithreason="challenged"is published). - The UAC retries the INVITE with an
Authorizationheader (sameCall-ID). VoiceBlender verifies the digest response against the credential you supplied. - On success a new inbound leg is surfaced via
leg.ringingwithauthenticated: trueandauth_usernameset — answer/reject it as usual. On failure VoiceBlender replies403 Forbiddenand the call is not surfaced.
VoiceBlender holds the supplied credential only in memory for the challenge's lifetime (SIP_INBOUND_AUTH_NONCE_TTL_SECONDS, default 60s) and never persists or returns it.
Request:
{
"realm": "vb.example",
"username": "alice",
"password": "s3cret",
"algorithm": "MD5",
"qop": ["auth"]
}
| Field | Type | Description |
|---|---|---|
realm | string (required) | Digest realm advertised in the challenge. |
username | string (optional) | Expected username. When set, a verified response whose username differs is rejected. When omitted, any username that verifies is accepted. |
password | string | Plaintext secret. Provide this or ha1. |
ha1 | string | Precomputed MD5(username:realm:password) (hex), to avoid sending a plaintext secret. Provide this or password. |
algorithm | string (optional) | MD5 (default), SHA-256, or SHA-512-256. |
qop | array (optional) | Advertised quality-of-protection, e.g. ["auth"]. Omit to leave qop out of the challenge. |
Response: 202 Accepted
{ "status": "challenging" }
Errors:
400— Not a SIP inbound leg, or missingrealm/credential404— Leg not found409— Leg is not inringingorearly_mediastate
POST /v1/legs/{id}/answer
Asynchronous. Queue the SIP 200 OK on a ringing or early-media inbound SIP leg. If the leg is in early_media state, the existing media pipeline and SDP are reused; if in ringing state, a new RTP session and codec negotiation are performed when the goroutine sends the 200 OK. Successful connection is observed via leg.connected.
Request: Optional body
{
"speech_detection": true,
"codec": "PCMA"
}
| Field | Type | Description |
|---|---|---|
speech_detection | bool (optional) | Override the server default for speaking.started / speaking.stopped events on this leg. Omit to use SPEECH_DETECTION_ENABLED (default false). |
codec | string (optional) | Force a specific codec for the answer SDP. One of PCMU, PCMA, G722, opus, AMR-WB, AMR-NB. Must appear in the remote offer's offered_codecs list (see leg.ringing). When omitted, the server picks the first codec present in both the remote offer and the engine's supported set. Ignored when the leg is already in early_media state — the codec is locked in at 183. |
Response: 202 Accepted
{ "status": "answering" }
Errors:
400— Not a SIP inbound leg, invalid request body, unknown codec name, or codec not in remote offer404— Leg not found409— Leg is not inringingorearly_mediastate
POST /v1/legs/{id}/early-media
Asynchronous. Queue early-media setup on a ringing inbound SIP leg. The goroutine sends SIP 183 Session Progress with SDP, sets up the RTP session and media pipeline, and transitions the leg to early_media state. Once in that state, audio can be played to the caller (e.g. custom ringback tones, announcements) and the leg can be added to a room — all before answering the call. Successful transition is observed via leg.early_media; setup failures surface as leg.command_failed with command="early_media".
Request: Optional body
{
"codec": "opus"
}
| Field | Type | Description |
|---|---|---|
codec | string (optional) | Force a specific codec for the 183 Session Progress SDP. One of PCMU, PCMA, G722, opus, AMR-WB, AMR-NB. Must appear in the remote offer's offered_codecs list. The codec chosen here is locked in for the subsequent /answer. When omitted, the server picks the first codec present in both the remote offer and the engine's supported set. |
Response: 202 Accepted
{ "status": "early_media" }
Errors:
400— Not a SIP inbound leg, unknown codec name, or codec not in remote offer404— Leg not found409— Leg is not inringingstate
POST /v1/legs/{id}/mute
Mute a leg. A muted leg's audio is excluded from the room mix and speaking events are suppressed. Taps (recording/STT) still receive the muted leg's own audio.
Request: Empty body
Response: 200 OK
{ "status": "muted" }
Errors: 404 — Leg not found
DELETE /v1/legs/{id}/mute
Unmute a leg.
Response: 200 OK
{ "status": "unmuted" }
Errors: 404 — Leg not found
POST /v1/legs/{id}/deaf
Deafen a leg. A deaf leg does not receive mixed audio from the room — the participant cannot hear other participants. The leg can still speak (its audio is still mixed for others).
Request: Empty body
Response: 200 OK
{ "status": "deaf" }
Errors: 404 — Leg not found
DELETE /v1/legs/{id}/deaf
Undeafen a leg. Restores the participant's ability to hear other participants.
Response: 200 OK
{ "status": "undeaf" }
Errors: 404 — Leg not found
POST /v1/legs/{id}/hold
Asynchronous. Queue a re-INVITE with sendonly SDP direction. The RTP timeout is paused while held, and a 2-hour auto-hangup timer starts. Successful hold is observed via leg.hold; failures surface as leg.command_failed with command="hold".
Response: 202 Accepted
{ "status": "holding" }
Errors:
404— Leg not found400— Not a SIP leg409— Hold not supported for this leg type (e.g. WhatsApp), or leg is neither connected nor held
DELETE /v1/legs/{id}/hold
Asynchronous. Queue a re-INVITE with sendrecv SDP direction. Successful resume is observed via leg.unhold; failures surface as leg.command_failed with command="unhold".
Response: 202 Accepted
{ "status": "unholding" }
Errors:
404— Leg not found400— Not a SIP leg409— Hold not supported for this leg type (e.g. WhatsApp), or leg is neither connected nor held
DELETE /v1/legs/{id}
Asynchronous. Queue a hangup. Sends SIP BYE (or closes the WebRTC connection) on a goroutine and tears down the leg. Final disconnect is observed via leg.disconnected.
Request: Optional body
{ "reason": "busy" }
| Field | Type | Description |
|---|---|---|
reason | string (optional) | Disconnect reason. Honored only for unanswered SIP inbound legs (state ringing or early_media); on connected legs the body is ignored and the leg is hung up with the legacy api_hangup reason. The reason value flows through to leg.disconnected's cdr.reason and selects the SIP final response sent to the caller. |
Reason → SIP final response (unanswered inbound only)
reason | SIP response |
|---|---|
busy | 486 Busy Here |
declined / rejected | 603 Decline |
unavailable | 480 Temporarily Unavailable |
not_found | 404 Not Found |
forbidden | 403 Forbidden |
server_error | 500 Server Internal Error |
Without a body, behavior is unchanged: BYE on connected legs (cdr.reason: "api_hangup"), or dialog cancel on unanswered inbound legs (cdr.reason: "caller_cancel").
Response: 202 Accepted
{ "status": "hanging_up" }
Errors:
400— Unknownreasonvalue404— Leg not found
POST /v1/legs/{id}/transfer
Transfer a SIP leg to a third party using SIP REFER (RFC 3515). Supports both blind and attended flavours. The leg must be in connected state.
- Blind transfer —
{"target": "sip:..."}. We send REFER inside the leg's existing dialog; the peer dials the target. Progress is reported back to us via NOTIFY sipfrag and surfaces asleg.transfer_progressevents. On terminal 2xx (leg.transfer_completed) the leg is hung up automatically. - Attended transfer —
{"target": "sip:...", "replaces_leg_id": "<uuid>"}. The named leg must already be inconnectedstate. Its dialog identity is embedded as aReplacesparameter on the Refer-To URI (RFC 3891) so the peer's INVITE replaces that dialog instead of creating a fresh one. Both legs are hung up on completion.
Request:
{
"target": "sip:bob@example.com",
"replaces_leg_id": "550e8400-e29b-41d4-a716-446655440000"
}
| Field | Type | Required | Description |
|---|---|---|---|
target | string | yes | SIP URI of the third party |
replaces_leg_id | string | no | Existing connected SIP leg whose dialog should be replaced (attended transfer). Omit for blind. |
Response: 202 Accepted
{ "status": "transfer_initiated" }
The REST call returns immediately after validating the request. The REFER is sent in the background and its outcome (accepted, rejected, or network error) surfaces on the event bus.
Events emitted: leg.transfer_initiated when the peer's 202 Accepted arrives, then leg.transfer_progress per NOTIFY sipfrag, then either leg.transfer_completed or leg.transfer_failed. If the peer rejects the REFER outright (e.g. 603 Decline), only leg.transfer_failed fires.
Errors:
400— Missing or invalidtarget(including URIs without a host such assip:)404— Leg not found409— Leg not connected, not a SIP leg, orreplaces_leg_idis not a connected SIP leg
Receiving a transfer (inbound REFER)
When a peer sends us a REFER (asks us to transfer its call), the handling depends on SIP_REFER_AUTO_DIAL:
SIP_REFER_AUTO_DIAL=true— the server accepts (202) and dials the target itself, driving the NOTIFY sipfrag progress. The app is notified vialeg.transfer_requestedbut does nothing.SIP_REFER_AUTO_DIAL=false(default) — the REFER is parked and surfaced asleg.transfer_requested(a decision request). The app drives the outcome with the commands below, keyed by the referrer leg ({id}= the leg that received the REFER). If no decision arrives withinSIP_REFER_CONSULT_TIMEOUT_MS(default 2000 ms), the REFER auto-declines with603(fail-closed) andleg.transfer_failedfires.
The typical app flow: on leg.transfer_requested, call accept, perform the re-bridge (route the other party to the target), optionally report progress, then complete. If the app can't perform the transfer, call decline instead.
All four return 202 Accepted on success, or 404 when there is no matching parked/accepted transfer for the leg. The same actions are available over VSI as accept_transfer, progress_transfer, complete_transfer, and decline_transfer (payload { "id": "<referrer_leg_id>", ... }).
POST /v1/legs/{id}/transfer/accept
Accepts the parked REFER: replies 202 Accepted to the referrer and sends NOTIFY 100 Trying, keeping the refer subscription open. No body.
{ "status": "accepting" }
POST /v1/legs/{id}/transfer/progress
Sends an interim sipfrag NOTIFY (e.g. 180 Ringing) on an accepted transfer so the referrer's UA reflects real progress.
| Field | Type | Required | Description |
|---|---|---|---|
status_code | int | yes | SIP status to relay (100–699) |
reason | string | no | Reason phrase; a sensible default is used when omitted |
Errors: 400 (status_code out of range), 404 (no accepted transfer for the leg).
POST /v1/legs/{id}/transfer/complete
Terminates an accepted transfer with a final sipfrag NOTIFY and emits leg.transfer_completed (2xx) or leg.transfer_failed. The referrer leg is left for the app to hang up.
| Field | Type | Required | Description |
|---|---|---|---|
success | bool | no | true sends 200 OK; otherwise a failure NOTIFY is sent |
status_code | int | no | Explicit terminal status (defaults: 200 on success, 500 on failure) |
reason | string | no | Reason phrase; defaulted from status_code when omitted |
{ "success": true }
POST /v1/legs/{id}/transfer/decline
Rejects a parked (not-yet-accepted) REFER, replying to the referrer with a non-2xx and emitting leg.transfer_failed.
| Field | Type | Required | Description |
|---|---|---|---|
code | int | no | SIP reject code (default 603) |
reason | string | no | Reason phrase (default Decline) |
Once a REFER has been accepted, use complete with success:false to signal failure — decline only applies before acceptance (404 otherwise).
POST /v1/legs/{id}/dtmf
Send DTMF digits on a leg (RFC 4733 telephone-event).
Request:
{ "digits": "123#" }
| Field | Type | Required | Description |
|---|---|---|---|
digits | string | yes | Digits to send (0-9, *, #) |
Response: 200 OK
{ "status": "sent" }
Errors:
400— Invalid JSON or empty digits404— Leg not found500— DTMF writer unavailable
DTMF broadcast
When a leg in a room receives DTMF (e.g. the SIP peer presses a key), VoiceBlender forwards
that digit to every other leg in the same room that has DTMF reception enabled. The originating
leg always emits its dtmf.received event regardless.
WebRTC legs are skipped as recipients (DTMF send over WebRTC is not yet implemented). The sending leg is excluded from the broadcast.
DTMF reception is on by default for every leg. To control it:
- At originate: set
accept_dtmf: falsein thePOST /v1/legsbody. - When adding to a room: set
accept_dtmf: falsein thePOST /v1/rooms/{id}/legsbody. - At runtime:
POST /v1/legs/{id}/dtmf/reject(disable) andPOST /v1/legs/{id}/dtmf/accept(re-enable).
The current state is exposed as accept_dtmf on the leg view returned by GET /v1/legs/{id}.
POST /v1/legs/{id}/dtmf/accept
Allow this leg to receive DTMF digits forwarded from other legs in the same room. Default state for new legs.
Response: 200 OK
{ "status": "dtmf_accepting" }
Errors:
404— Leg not found
POST /v1/legs/{id}/dtmf/reject
Block this leg from receiving DTMF digits forwarded from other legs in the same room. The leg's own DTMF (received from its far end) still emits a dtmf.received event.
Response: 200 OK
{ "status": "dtmf_rejecting" }
Example:
curl -X POST http://localhost:8080/v1/legs/abc-123/dtmf/reject
Errors:
404— Leg not found
Real-Time Text (RTT, ITU-T T.140 / RFC 4103)
VoiceBlender can negotiate an m=text media line alongside m=audio on SIP legs and exchange UTF-8 text in real time using the RFC 4103 RTP payload with RFC 2198 redundancy. Useful for accessibility (deaf / hard-of-hearing callers) and totally-conversational compliance scenarios.
- Inbound calls automatically accept any
m=textsection the caller offers — no configuration needed. - Outbound calls offer RTT only when the originate request sets
"rtt": true(seePOST /v1/legs). Peers that don't speak RFC 4103 simply ignore or reject the section, and audio still negotiates normally.
WebRTC legs do not currently bridge RTT (browsers use RFC 8865 over data channels rather than RFC 4103 over RTP).
POST /v1/legs/{id}/rtt
Send a chunk of UTF-8 text on the leg's RTT stream. Requires that the SDP exchange agreed on m=text.
Request:
{ "text": "hello\n" }
| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | UTF-8 text. May include T.140 control codes such as backspace () or CR/LF. |
Response: 200 OK
{ "status": "sent" }
Example:
curl -X POST http://localhost:8080/v1/legs/abc-123/rtt \
-H 'Content-Type: application/json' \
-d '{"text":"hello"}'
Errors:
400— Invalid JSON or empty text404— Leg not found409— RTT was not negotiated for this leg (peer didn't includem=text, orRTT_ENABLED=false)500— Send failed
POST /v1/legs/{id}/rtt/accept
Allow this leg to receive RTT text broadcast from other legs in the same room and to emit rtt.received events. Default for new legs.
Response: 200 OK { "status": "rtt_accepting" }
POST /v1/legs/{id}/rtt/reject
Block this leg from receiving RTT text broadcast from other legs and suppress rtt.received events for it.
Response: 200 OK { "status": "rtt_rejecting" }
POST /v1/legs/{id}/play
Start audio playback to a leg. Fetches audio from a URL or generates a built-in telephone tone.
Request (URL):
{
"url": "https://example.com/greeting.wav",
"mime_type": "audio/wav"
}
Request (tone):
{
"tone": "us_ringback"
}
| Field | Type | Required | Description |
|---|---|---|---|
url | string | one of url or tone | URL of the audio file |
tone | string | one of url or tone | Built-in telephone tone name (see below) |
mime_type | string | with url | MIME type (audio/wav) |
repeat | integer | no | Repeat count (0/1=once, -1=infinite) |
volume | integer | no | Volume adjustment (-8 to 8, ~3dB/step) |
url and tone are mutually exclusive — provide exactly one.
Tone names: Format is {country}_{type} or bare {type} (defaults to US).
- Types:
ringback,busy,dial,congestion - Countries:
us,gb,de,fr,au,jp,it,in,br,pl,ru - Examples:
us_ringback,gb_busy,dial(=us_dial)
Tones play indefinitely until stopped via DELETE /v1/legs/{id}/play/{playbackID}.
Response: 200 OK
{ "playback_id": "pb-a1b2c3d4", "status": "playing" }
Playback runs asynchronously. Events playback.started and playback.finished are emitted.
Errors:
400— Invalid JSON, missing url/tone, both url and tone provided404— Leg not found409— Leg has no audio writer (not yet connected)
DELETE /v1/legs/{id}/play/{playbackID}
Stop audio playback on a leg.
Response: 200 OK
{ "status": "stopped" }
Errors: 404 — No playback in progress
PATCH /v1/legs/{id}/play/{playbackID}
Change the volume of an active leg playback. Takes effect immediately on the next audio frame. The new level persists for the lifetime of the playback, including across loop iterations.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
volume | integer | yes | Volume adjustment (-8 to 8, ~3dB per step, 0 = unchanged) |
Response: 200 OK
{ "status": "ok" }
Errors:
400— Invalid JSON or volume out of range404— Playback not found
POST /v1/legs/{id}/tts
Synthesize speech and play it on a leg.
Request:
{
"text": "Hello, how can I help you?",
"voice": "Rachel",
"provider": "elevenlabs",
"model_id": "eleven_multilingual_v2",
"volume": 0
}
Request (Google Gemini TTS):
{
"text": "Movies, oh my gosh, I just love them.",
"voice": "Achernar",
"provider": "google",
"model_id": "gemini-2.5-pro-tts",
"language": "en-US",
"prompt": "Read aloud in a warm, welcoming tone."
}
Request (Deepgram TTS):
{
"text": "Hello, how can I help you?",
"voice": "aura-2-asteria-en",
"provider": "deepgram",
"volume": 0
}
Request (Azure TTS):
{
"text": "Hello, how can I help you?",
"voice": "en-US-JennyNeural",
"provider": "azure",
"volume": 0
}
| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | Text to synthesize |
voice | string | yes | Provider-specific voice identifier. ElevenLabs: voice name or ID. AWS Polly: voice ID (e.g. Joanna, Matthew). Google Cloud: voice name — either full format (e.g. en-US-Neural2-F) or short name for Gemini models (e.g. Achernar, Kore). Deepgram: model name (e.g. aura-2-asteria-en). Azure: voice name (e.g. en-US-JennyNeural, pl-PL-MarekNeural). |
provider | string | no | TTS provider: "elevenlabs" (default), "aws", "google", "deepgram", or "azure" |
model_id | string | no | Provider-specific model/engine. ElevenLabs: model ID. AWS Polly: engine (standard, neural, long-form, generative; default neural). Google Cloud: model name (e.g. gemini-2.5-pro-tts, chirp3-hd). Not used for Deepgram or Azure (voice selects the model). |
language | string | no | Language code (e.g. "en-US", "pl-pl"). Required for Google Gemini TTS voices that use short names (e.g. Achernar). Auto-extracted from full voice names like en-US-Neural2-F or en-US-JennyNeural. |
prompt | string | no | Style/tone instruction for promptable voice models (Google Gemini TTS only). E.g. "Read aloud in a warm, welcoming tone." |
volume | integer | no | Volume adjustment in dB (-8 to 8, default 0) |
api_key | string | no | ElevenLabs: API key override (falls back to ELEVENLABS_API_KEY env var). AWS: optional ACCESS_KEY:SECRET_KEY override (falls back to default AWS credential chain). Google Cloud: optional API key override (falls back to Application Default Credentials). Deepgram: API key override (falls back to DEEPGRAM_API_KEY env var). Azure: subscription key override (falls back to AZURE_SPEECH_KEY env var). |
Providers:
elevenlabs— ElevenLabs streaming TTS API (default). Requires an API key.aws— Amazon Polly. Uses the default AWS credential chain (env vars, IAM role, shared credentials file). No API key required unless overriding credentials per-request.google— Google Cloud Text-to-Speech. Uses Application Default Credentials (ADC). No API key required unless overriding per-request. Supports all voice types: Standard, WaveNet, Neural2, Studio, Chirp 3 HD, and Gemini TTS. For Gemini models (e.g.gemini-2.5-pro-tts), setmodel_idandlanguageexplicitly; usepromptfor style instructions.deepgram— Deepgram TTS API. Requires an API key. Thevoicefield selects the model (e.g.aura-2-asteria-en).azure— Azure Cognitive Speech Services. Requires a subscription key (AZURE_SPEECH_KEY). Voice names follow the{lang}-{region}-{Name}pattern (e.g.en-US-JennyNeural). Language is auto-extracted from the voice name.
Response: 200 OK
{ "tts_id": "tts-a1b2c3d4", "status": "playing" }
Events tts.started and tts.finished are emitted.
Caching: When TTS_CACHE_ENABLED=true, identical requests (same text, voice, model, language, and prompt) are served from the disk cache stored in TTS_CACHE_DIR, skipping the external provider call. The cache persists across restarts; to clear it, delete the files in that directory. Set TTS_CACHE_INCLUDE_API_KEY=true to scope the cache per API key (needed when different keys access different voice clones).
Errors:
400— Invalid JSON, missing text/voice, volume out of range404— Leg not found409— Leg has no audio writer503— No API key provided for the selected provider
POST /v1/legs/{id}/record
Start recording a leg to a WAV file.
For SIP legs, recording is stereo (16-bit PCM at the codec's native sample rate):
- Left channel — incoming audio (what the remote party says)
- Right channel — outgoing audio (what we send, including agent TTS)
For legs in a room, recording is stereo at 16kHz:
- Left channel — participant's incoming audio (before mix)
- Right channel — mixed-minus-self (what the participant hears)
Request:
{
"storage": "s3",
"s3_bucket": "my-recordings",
"s3_region": "eu-west-1",
"s3_endpoint": "https://s3.example.com",
"s3_prefix": "calls/",
"s3_access_key": "AKIA...",
"s3_secret_key": "wJalr..."
}
| Field | Type | Required | Description |
|---|---|---|---|
storage | string | no | "file" (default) — local disk, "s3" — upload to S3 after recording stops |
s3_bucket | string | no | S3 bucket name. Overrides S3_BUCKET env var. Required if env var is not set. |
s3_region | string | no | AWS region. Overrides S3_REGION env var. Default us-east-1. |
s3_endpoint | string | no | Custom S3 endpoint (MinIO, etc.). Overrides S3_ENDPOINT env var. |
s3_prefix | string | no | Key prefix (e.g. recordings/). Overrides S3_PREFIX env var. |
s3_access_key | string | no | AWS access key ID. Overrides default credential chain. |
s3_secret_key | string | no | AWS secret access key. Must be set together with s3_access_key. |
When s3_bucket is provided, a per-request S3 backend is created using the supplied config. Otherwise the server-level S3 backend (from env vars) is used.
Response: 200 OK
{
"status": "recording",
"file": "/tmp/recordings/20260301_110500_a1b2c3d4.wav"
}
Recording runs asynchronously. Events recording.started and recording.finished are emitted. When storage=s3, the file field in the stop response and the recording.finished event will contain an s3://bucket/key URI.
Errors:
400— Invalid storage type, S3 not configured, or invalid S3 credentials404— Leg not found409— Leg has no audio reader500— Failed to create recording file
DELETE /v1/legs/{id}/record
Stop recording a leg.
Response: 200 OK
{
"status": "stopped",
"file": "/tmp/recordings/20260301_110500_a1b2c3d4.wav"
}
Errors: 404 — No recording in progress
POST /v1/legs/{id}/record/pause
Pause the active recording for a leg. While paused, the WAV continues to advance in real time but the audio is replaced with silence, so the file preserves the full session duration with a clearly silent gap where sensitive data was excluded (e.g. credit-card capture, PII exchange). Both sides of a stereo recording are silenced together.
Idempotent: calling while already paused returns status: already_paused.
Response: 200 OK
{"status": "paused"}
or, if already paused:
{"status": "already_paused"}
Emits a recording.paused event.
Errors: 404 — No recording in progress
POST /v1/legs/{id}/record/resume
Resume a previously paused leg recording. Idempotent: calling while not paused returns status: not_paused.
Response: 200 OK
{"status": "resumed"}
Emits a recording.resumed event.
Errors: 404 — No recording in progress
Example — pause around sensitive data:
# Start recording
curl -X POST http://localhost:8080/v1/legs/$LEG_ID/record
# ... agent collects call details ...
# Pause before asking for credit card
curl -X POST http://localhost:8080/v1/legs/$LEG_ID/record/pause
# ... agent collects card number + CVV ...
# Resume for the rest of the call
curl -X POST http://localhost:8080/v1/legs/$LEG_ID/record/resume
# Stop when done
curl -X DELETE http://localhost:8080/v1/legs/$LEG_ID/record
POST /v1/legs/{id}/stt
Start real-time speech-to-text transcription on a leg.
Request:
{
"language": "en",
"partial": true,
"provider": "elevenlabs"
}
| Field | Type | Required | Description |
|---|---|---|---|
language | string | no | Language code (e.g. "en", "es") |
partial | boolean | no | Emit partial (non-final) transcripts |
provider | string | no | STT provider: "elevenlabs" (default), "deepgram", or "azure" |
api_key | string | no | API key override (falls back to ELEVENLABS_API_KEY, DEEPGRAM_API_KEY, or AZURE_SPEECH_KEY env var depending on provider) |
Providers:
elevenlabs— ElevenLabs real-time STT via WebSocket (default). Uses Scribe v2 model.deepgram— Deepgram real-time STT via WebSocket. Uses Nova-3 model. Audio is sent as raw binary PCM frames.azure— Azure Cognitive Speech Services real-time STT via WebSocket. Requires a subscription key (AZURE_SPEECH_KEY) and region (AZURE_SPEECH_REGION). Language defaults to"en-US".
Response: 200 OK
{ "status": "stt_started", "leg_id": "550e8400-..." }
Transcripts are delivered via stt.text webhook events.
Errors:
404— Leg not found409— Leg not connected, STT already running, or leg has no audio reader503— No API key provided for the selected provider
DELETE /v1/legs/{id}/stt
Stop speech-to-text on a leg.
Response: 200 OK
{ "status": "stt_stopped" }
Errors: 404 — No STT in progress
POST /v1/legs/{id}/agent/elevenlabs
Attach an ElevenLabs ConvAI agent to a leg.
Request:
{
"agent_id": "abc123",
"first_message": "Hello!",
"language": "en",
"dynamic_variables": { "name": "Alice" },
"api_key": "xi-..."
}
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | yes | ElevenLabs agent ID |
first_message | string | no | Override the agent's first message |
language | string | no | Language code (e.g. "en", "es") |
dynamic_variables | object | no | Key-value pairs passed to the agent as dynamic variables |
api_key | string | no | API key override (falls back to ELEVENLABS_API_KEY env var) |
Response: 200 OK — { "status": "agent_started", "leg_id": "..." }
Errors: 400 — Invalid JSON or missing agent_id · 404 — Leg not found · 409 — Leg not connected, agent already attached, or no audio reader/writer · 503 — No API key
POST /v1/legs/{id}/agent/vapi
Attach a VAPI agent to a leg.
Request:
{
"assistant_id": "asst_xyz",
"first_message": "Hello!",
"variable_values": { "name": "Alice" },
"api_key": "vapi-..."
}
| Field | Type | Required | Description |
|---|---|---|---|
assistant_id | string | yes | VAPI assistant ID |
first_message | string | no | Override the agent's first message |
variable_values | object | no | Key-value pairs passed as VAPI variable values |
api_key | string | no | API key override (falls back to VAPI_API_KEY env var) |
Response: 200 OK — { "status": "agent_started", "leg_id": "..." }
Errors: 400 — Invalid JSON or missing assistant_id · 404 — Leg not found · 409 — Leg not connected, agent already attached, or no audio reader/writer · 503 — No API key
POST /v1/legs/{id}/agent/pipecat
Attach a self-hosted Pipecat bot to a leg. Audio is exchanged as protobuf-encoded binary frames (16kHz 16-bit PCM mono). No API key required.
Request:
{
"websocket_url": "ws://my-bot:8765"
}
| Field | Type | Required | Description |
|---|---|---|---|
websocket_url | string | yes | WebSocket URL of the Pipecat bot |
Response: 200 OK — { "status": "agent_started", "leg_id": "..." }
Errors: 400 — Invalid JSON or missing websocket_url · 404 — Leg not found · 409 — Leg not connected, agent already attached, or no audio reader/writer
POST /v1/legs/{id}/agent/deepgram
Attach a Deepgram Voice Agent to a leg. Audio is exchanged as raw binary PCM frames (16kHz 16-bit PCM mono).
Request:
{
"settings": {
"agent": {
"listen": { "provider": { "type": "deepgram", "model": "nova-3" } },
"think": { "provider": { "type": "open_ai", "model": "gpt-4o-mini" } },
"speak": { "provider": { "type": "deepgram", "model": "aura-2-asteria-en" } }
}
},
"greeting": "Hello!",
"language": "en",
"api_key": "dg-..."
}
| Field | Type | Required | Description |
|---|---|---|---|
settings | object | no | Full Deepgram agent settings object. When omitted, sensible defaults are used (nova-3 STT, gpt-4o-mini LLM, aura-2-asteria-en TTS). |
greeting | string | no | Agent greeting message |
language | string | no | Language code (e.g. "en", "es") |
api_key | string | no | API key override (falls back to DEEPGRAM_API_KEY env var) |
Response: 200 OK — { "status": "agent_started", "leg_id": "..." }
Errors: 400 — Invalid JSON · 404 — Leg not found · 409 — Leg not connected, agent already attached, or no audio reader/writer · 503 — No API key
Agent notes (all providers):
- Standalone leg: Agent reads/writes audio directly with resampling to 16kHz.
- Leg in a room: Agent hears only that leg (via mixer tap) and speaks to everyone (via playback source).
- Agent events (
agent.connected,agent.disconnected,agent.user_transcript,agent.agent_response) are delivered via webhooks.
POST /v1/legs/{id}/agent/message
Inject a context message or instruction into a running agent session on a leg. This is provider-agnostic — the session routes the message using the appropriate provider mechanism.
Supported providers:
- Deepgram — sends
InjectAgentMessagevia WebSocket - Pipecat — sends a protobuf
TextFramevia WebSocket - VAPI — sends
add-messagevia HTTP control URL - ElevenLabs — not supported (returns
501)
Request:
{
"message": "The customer's name is John and their order number is 12345."
}
| Field | Type | Required | Description |
|---|---|---|---|
message | string | yes | Context or instruction to inject into the running agent session |
Response: 200 OK
{ "status": "message_sent" }
Errors: 400 — Invalid JSON or missing message · 404 — No agent attached to this leg · 409 — Agent session not running · 501 — Provider does not support message injection
DELETE /v1/legs/{id}/agent
Detach the agent from a leg (provider-agnostic).
Response: 200 OK
{ "status": "agent_stopped" }
Errors: 404 — No agent attached to this leg
Rooms
A room is a multi-party audio conference. Legs added to a room hear mixed audio from all other participants (mixed-minus-self).
Room Object
{
"id": "room-123",
"sample_rate": 16000,
"participants": [
{ "id": "leg-uuid", "type": "sip_inbound", "state": "connected", "room_id": "room-123" }
]
}
POST /v1/rooms
Create a room.
Request:
{ "id": "my-custom-room-id", "sample_rate": 48000 }
| Field | Type | Required | Description |
|---|---|---|---|
id | string | no | Custom room ID. Auto-generated UUID if omitted. |
sample_rate | integer | no | Mixer sample rate in Hz. Allowed values: 8000, 16000, 48000. Default: 16000. Higher rates preserve more audio fidelity but use proportionally more CPU and memory. |
webhook_url | string | no | Per-room webhook URL. Events for this room are routed exclusively to this URL instead of global webhooks. |
webhook_secret | string | no | HMAC-SHA256 signing secret for the per-room webhook. |
Response: 201 Created — Room object (empty participants)
Errors:
400— Invalid sample rate409— Room ID already exists
GET /v1/rooms
List all rooms with their participants.
Response: 200 OK — Array of Room objects
GET /v1/rooms/{id}
Get a room with its participants.
Response: 200 OK — Room object
Errors: 404 — Room not found
DELETE /v1/rooms/{id}
Delete a room. All participants are hung up.
Response: 200 OK
{ "status": "deleted" }
Errors: 404 — Room not found
POST /v1/rooms/{id}/legs
Add a leg to a room, or move it from another room. The leg must be in connected or early_media state. If the leg is a ringing inbound SIP leg, it is automatically answered before being added. If the room does not exist, it is automatically created.
If the leg is already in a different room, it is atomically moved — detached from the source mixer and immediately added to the target mixer with minimal audio gap. If the target room does not exist, it is auto-created.
Request:
{ "leg_id": "550e8400-e29b-41d4-a716-446655440000" }
Join already muted / deaf:
{
"leg_id": "550e8400-e29b-41d4-a716-446655440000",
"mute": true,
"deaf": false
}
| Field | Type | Required | Description |
|---|---|---|---|
leg_id | string | yes | ID of the leg to add |
mute | bool | no | Apply this mute state to the leg atomically before it joins the mixer — prevents the race where one frame of un-muted audio leaks into the mix between add and /mute. Omit to leave current state untouched (useful on move). |
deaf | bool | no | Apply this deaf state to the leg atomically before it joins the mixer. Omit to leave current state untouched. |
role | string | no | Apply a routing role atomically before the leg enters the mixer. The room's routing matrix (see /v1/rooms/{id}/routing) decides who hears whom based on roles, so passing role on join guarantees no audio bleed between the leg appearing in the mix and the matrix being applied. Pass an empty string to clear the role (full mesh). Omit to leave the current role untouched. |
Response (added): 200 OK
{ "status": "added" }
Response (moved from another room): 200 OK
{ "status": "moved", "from": "room-123", "to": "room-456" }
Events leg.left_room and leg.joined_room are emitted on move.
Errors:
400— Invalid JSON, leg not found, leg not connected, or leg already in this room
DELETE /v1/rooms/{id}/legs/{legID}
Remove a leg from a room (without hanging it up).
Response: 200 OK
{ "status": "removed" }
Errors: 400 — Room or leg not found
Room Bridges
A bridge joins two rooms' mixers so audio flows between them, without merging their participant sets. Both rooms must already exist and use the same sample rate (no resampling is performed on the bridge). Mixed-minus-self in each mixer prevents the other room's audio from echoing back across the bridge.
direction is always relative to the room in the request path ({id}):
direction | Path room sends | Path room receives |
|---|---|---|
bidirectional (default) | yes | yes |
send | yes | no |
receive | no | yes |
none | no | no |
A room may hold several bridges (e.g. A↔B and A↔C). The mixer of a bridged
room is kept running even when it has no legs, so a one-way receive/send
bridge into an otherwise empty room works (e.g. a recorder/agent room).
Cycle warning: bridging rooms into a cycle (A→B→C→A) with feedback-enabled directions causes audio feedback. Use one-way directions to break loops.
Audio only: a bridge relays mixed PCM audio between the two rooms. It does not relay DTMF (RFC 4733 telephone-events) or RTT (T.140) — those are broadcast only among the legs within a single room, so digits/text entered in one bridged room are not delivered to participants of the other.
The room.bridged / room.bridge_updated / room.unbridged webhook events
report room_a_id (the room the bridge was created from) and room_b_id, and
their direction is canonical relative to room_a_id (bidirectional,
a_to_b, b_to_a, or none) — independent of which room you call the REST
endpoint from.
POST /v1/rooms/{id}/bridges
Bridge the room in the path to another room.
Request:
{ "room_id": "room-b", "direction": "bidirectional" }
| Field | Description |
|---|---|
id | Optional custom bridge ID (auto-generated UUID if omitted) |
room_id | The other room to join (required) |
direction | bidirectional (default), send, receive, or none |
curl -X POST http://localhost:8080/v1/rooms/room-a/bridges \
-H 'Content-Type: application/json' \
-d '{"room_id":"room-b","direction":"bidirectional"}'
Response: 201 Created
{ "id": "b1f2…", "room_id": "room-b", "direction": "bidirectional", "sample_rate": 16000 }
Errors: 400 — invalid JSON, self-bridge, sample-rate mismatch, or invalid
direction · 404 — path room or room_id not found · 409 — a bridge between
these rooms already exists
GET /v1/rooms/{id}/bridges
List every bridge involving this room. direction and room_id in each entry
are relative to the room in the path.
Response: 200 OK — array of bridge objects. Errors: 404 — room not found
GET /v1/rooms/{id}/bridges/{bridgeID}
Response: 200 OK — bridge object. Errors: 404 — bridge not found for this room
PATCH /v1/rooms/{id}/bridges/{bridgeID}
Change the bridge's audio flow live (no audio interruption, no participant churn).
Request:
{ "direction": "send" }
curl -X PATCH http://localhost:8080/v1/rooms/room-a/bridges/b1f2 \
-H 'Content-Type: application/json' -d '{"direction":"send"}'
Response: 200 OK — updated bridge object. Errors: 400 — invalid or
missing direction · 404 — bridge not found for this room
DELETE /v1/rooms/{id}/bridges/{bridgeID}
Tear the bridge down. Deleting either bridged room also tears down its bridges
automatically (emitting room.unbridged with reason: "room_deleted").
Response: 200 OK
{ "status": "deleted" }
Errors: 404 — bridge not found for this room
Audio routing matrix
Inside a single room, the audio routing matrix controls which participants hear which other participants. The default is full mesh — every leg hears every other leg. The matrix lets you express asymmetric audio (one-way listens, whisper, supervisor monitor) without spinning up extra bridges.
The matrix is keyed by role, an operator-supplied string set on each leg
("customer", "agent", "supervisor", or any other free-form value). Each
row of the matrix lists the source roles a listener role is allowed to hear:
{
"matrix": {
"customer": ["agent"],
"agent": ["customer", "supervisor"],
"supervisor": ["customer", "agent"]
}
}
This is the barge-in / whisper pattern:
- Customer and agent hear each other.
- Supervisor hears both customer and agent.
- Agent also hears the supervisor (whisper / coaching).
- Customer does NOT hear the supervisor.
Semantics
For a listener L with role R_L and a source S with role R_S (and L != S):
R_L == ""(unroled listener) → hears everyone (full mesh).R_S == ""(unroled source) → not heard by any matrix-routed listener.matrix[R_L]unset → listener defaults to full mesh.matrix[R_L] == []→ listener hears nothing (isolated).- Otherwise → listener hears
SiffR_Sis inmatrix[R_L].
mute (source contributes silence) and deaf (listener gets no output) still
apply on top of the matrix.
Atomicity (no bleed)
To guarantee that a supervisor joining mid-call cannot momentarily be heard
by the customer, pass "role" on POST /v1/rooms/{id}/legs — the role is
set and the matrix is recomputed under the same mutex acquisition that adds
the participant to the mixer, so the very first mixTick that sees the new
leg already has the correct routing. Mid-call role changes via
PATCH /v1/legs/{id}/role take effect on the next mixTick (≤ 20 ms) and
are also atomic: either every leg's allow-set reflects the change or none of
them do.
GET /v1/rooms/{id}/routing
Return the current routing matrix.
Response: 200 OK
{
"matrix": {
"customer": ["agent"],
"agent": ["customer", "supervisor"],
"supervisor": ["customer", "agent"]
}
}
Roles absent from matrix default to full mesh.
Errors: 404 — room not found
PUT /v1/rooms/{id}/routing
Replace the full routing matrix. Recomputes every leg's per-listener source whitelist in one mixer-mutex acquisition; the next mix tick (≤ 20 ms) reflects the new routing.
Request:
{
"matrix": {
"customer": ["agent"],
"agent": ["customer", "supervisor"],
"supervisor": ["customer", "agent"]
}
}
Response: 200 OK — returns the updated matrix in the same shape as GET.
Emits room.routing_changed with reason: "set".
Errors: 400 — invalid JSON; 404 — room not found
PATCH /v1/rooms/{id}/routing
Replace selected rows of the matrix. Useful for adjusting a single role's
allow-list without restating the whole matrix. Pass "sources": null on an
update to clear that row back to full mesh.
Request:
{
"updates": [
{ "listener_role": "supervisor", "sources": ["customer", "agent", "trainee"] },
{ "listener_role": "trainee", "sources": null }
]
}
Response: 200 OK — returns the updated matrix.
Emits room.routing_changed with reason: "update".
Errors: 400 — invalid JSON; 404 — room not found
PATCH /v1/legs/{id}/role
Change a leg's routing role. If the leg is currently in a room, the room's
matrix-derived allow-sets are recomputed atomically and room.routing_changed
fires with reason: "leg_role_changed". leg.role_changed is always emitted.
Request:
{ "role": "supervisor" }
Pass an empty string to clear the role (the leg falls back to full mesh).
Response: 200 OK — returns the updated LegView.
Errors: 400 — invalid JSON; 404 — leg not found
POST /v1/rooms/{id}/play
Play audio to a room. Accepts a URL or a built-in telephone tone (same tone names as leg playback).
Request (URL):
{
"url": "https://example.com/announcement.wav",
"mime_type": "audio/wav"
}
Request (tone):
{
"tone": "us_ringback"
}
| Field | Type | Required | Description |
|---|---|---|---|
url | string | one of url or tone | URL of the audio file |
tone | string | one of url or tone | Built-in telephone tone name |
mime_type | string | with url | MIME type (audio/wav) |
repeat | integer | no | Repeat count (0/1=once, -1=infinite) |
volume | integer | no | Volume adjustment (-8 to 8, ~3dB/step) |
Response: 200 OK
{ "playback_id": "pb-a1b2c3d4", "status": "playing" }
Errors:
400— Invalid JSON, missing url/tone, both url and tone provided404— Room not found409— Room has no participants
DELETE /v1/rooms/{id}/play/{playbackID}
Stop room playback.
Response: 200 OK
{ "status": "stopped" }
Errors: 404 — No playback in progress
PATCH /v1/rooms/{id}/play/{playbackID}
Change the volume of an active room playback. Takes effect immediately on the next audio frame. The new level persists for the lifetime of the playback, including across loop iterations.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
volume | integer | yes | Volume adjustment (-8 to 8, ~3dB per step, 0 = unchanged) |
Response: 200 OK
{ "status": "ok" }
Errors:
400— Invalid JSON or volume out of range404— Playback not found
POST /v1/rooms/{id}/tts
Synthesize speech and play it into a room.
Request:
{
"text": "Attention please.",
"voice": "Rachel",
"provider": "elevenlabs",
"model_id": "eleven_multilingual_v2",
"volume": 0
}
| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | Text to synthesize |
voice | string | yes | Provider-specific voice identifier. ElevenLabs: voice name or ID. AWS Polly: voice ID (e.g. Joanna, Matthew). Google Cloud: voice name — either full format (e.g. en-US-Neural2-F) or short name for Gemini models (e.g. Achernar, Kore). Azure: voice name (e.g. en-US-JennyNeural). |
provider | string | no | TTS provider: "elevenlabs" (default), "aws", "google", "deepgram", or "azure" |
model_id | string | no | Provider-specific model/engine. ElevenLabs: model ID. AWS Polly: engine (standard, neural, long-form, generative; default neural). Google Cloud: model name (e.g. gemini-2.5-pro-tts, chirp3-hd). Not used for Deepgram or Azure. |
language | string | no | Language code (e.g. "en-US", "pl-pl"). Required for Google Gemini TTS voices that use short names. Auto-extracted from full voice names. |
prompt | string | no | Style/tone instruction for promptable voice models (Google Gemini TTS only). |
volume | integer | no | Volume adjustment in dB (-8 to 8, default 0) |
api_key | string | no | ElevenLabs: API key override (falls back to ELEVENLABS_API_KEY env var). AWS: optional ACCESS_KEY:SECRET_KEY override (falls back to default AWS credential chain). Google Cloud: optional API key override (falls back to Application Default Credentials). Deepgram: API key override (falls back to DEEPGRAM_API_KEY env var). Azure: subscription key override (falls back to AZURE_SPEECH_KEY env var). |
Response: 200 OK
{ "tts_id": "tts-a1b2c3d4", "status": "playing" }
Events tts.started and tts.finished are emitted.
Caching: When TTS_CACHE_ENABLED=true, identical requests (same text, voice, model, language, and prompt) are served from the disk cache stored in TTS_CACHE_DIR, skipping the external provider call. The cache persists across restarts; to clear it, delete the files in that directory. Set TTS_CACHE_INCLUDE_API_KEY=true to scope the cache per API key (needed when different keys access different voice clones).
Errors:
400— Invalid JSON, missing text/voice, volume out of range404— Room not found409— Room has no participants503— No API key provided for the selected provider
POST /v1/rooms/{id}/record
Start recording the full room mix to a WAV file (16-bit, mono, at the room's configured sample rate).
Request:
{
"storage": "s3",
"multi_channel": true,
"s3_bucket": "my-recordings",
"s3_region": "eu-west-1",
"s3_access_key": "AKIA...",
"s3_secret_key": "wJalr..."
}
| Field | Type | Required | Description |
|---|---|---|---|
storage | string | no | "file" (default) — local disk, "s3" — upload to S3 after recording stops |
multi_channel | boolean | no | When true, produce a single multi-channel WAV file with one track per participant (time-aligned with silence padding), in addition to the full mix. Default false. |
s3_bucket | string | no | S3 bucket name. Overrides S3_BUCKET env var. Required if env var is not set. |
s3_region | string | no | AWS region. Overrides S3_REGION env var. Default us-east-1. |
s3_endpoint | string | no | Custom S3 endpoint (MinIO, etc.). Overrides S3_ENDPOINT env var. |
s3_prefix | string | no | Key prefix (e.g. recordings/). Overrides S3_PREFIX env var. |
s3_access_key | string | no | AWS access key ID. Overrides default credential chain. |
s3_secret_key | string | no | AWS secret access key. Must be set together with s3_access_key. |
When s3_bucket is provided, a per-request S3 backend is created. Otherwise the server-level S3 backend (from env vars) is used.
Response: 200 OK
{
"status": "recording",
"file": "/tmp/recordings/20260301_110500_a1b2c3d4.wav"
}
When storage=s3, the file field in the stop response and the recording.finished event will contain an s3://bucket/key URI.
Multi-Channel Recording
When multi_channel: true is set, a single multi-channel WAV file is produced alongside the full mix. Each participant gets their own channel (track) within this file, with silence padding so all tracks are time-aligned to the recording start. Participants that join mid-recording get a new channel; participants that leave have silence written for the remainder.
This gives you one file ready for post-production — each speaker on a clean isolated channel for independent editing, noise reduction, and level adjustment.
The per-participant audio capture uses a dedicated mixer tap that is independent of STT/agent taps, so multi-channel recording and STT can run simultaneously without conflict.
Errors:
400— Invalid storage type, S3 not configured, or invalid S3 credentials404— Room not found409— Room has no participants500— Failed to create recording file
DELETE /v1/rooms/{id}/record
Stop room recording.
Response: 200 OK
Standard (mono) recording:
{
"status": "stopped",
"file": "/tmp/recordings/20260301_110500_a1b2c3d4.wav"
}
Multi-channel recording — includes a single multi-channel WAV with channel metadata:
{
"status": "stopped",
"file": "/tmp/recordings/20260301_110500_a1b2c3d4.wav",
"multi_channel_file": "/tmp/recordings/20260301_110500_multichannel_e5f6a7b8.wav",
"channels": {
"550e8400-e29b-41d4-a716-446655440000": { "channel": 0, "start_ms": 0, "end_ms": 45000 },
"660f9500-f3ac-52e5-b827-557766551111": { "channel": 1, "start_ms": 1200, "end_ms": 45000 }
}
}
| Field | Type | Description |
|---|---|---|
file | string | Path/URI of the full mix recording (mono) |
multi_channel_file | string | Path/URI of the multi-channel WAV file. Only present when multi_channel: true was used. |
channels | object | Map of leg ID to channel metadata. Only present when multi_channel: true was used. |
channels[].channel | integer | Zero-based channel index in the multi-channel WAV |
channels[].start_ms | integer | Milliseconds from recording start when this participant joined |
channels[].end_ms | integer | Milliseconds from recording start when this participant's audio ends |
Errors: 404 — No recording in progress
POST /v1/rooms/{id}/record/pause
Pause the active room recording. The room-mix WAV is silenced. If multi_channel: true was used when starting the recording, every per-participant track is paused too — including tracks for participants that join the room while the recording is paused, so sensitive data can't leak in via a new leg.
Idempotent: returns status: already_paused if already paused.
Response: 200 OK
{"status": "paused"}
Emits a recording.paused event.
Errors: 404 — No recording in progress
POST /v1/rooms/{id}/record/resume
Resume a previously paused room recording. Resumes every per-participant track if multi-channel recording is active. Idempotent: returns status: not_paused if not paused.
Response: 200 OK
{"status": "resumed"}
Emits a recording.resumed event.
Errors: 404 — No recording in progress
POST /v1/rooms/{id}/stt
Start real-time speech-to-text on all participants in a room.
Request:
{
"language": "en",
"partial": true,
"provider": "elevenlabs"
}
| Field | Type | Required | Description |
|---|---|---|---|
language | string | no | Language code |
partial | boolean | no | Emit partial (non-final) transcripts |
provider | string | no | STT provider: "elevenlabs" (default), "deepgram", or "azure" |
api_key | string | no | API key override (falls back to ELEVENLABS_API_KEY, DEEPGRAM_API_KEY, or AZURE_SPEECH_KEY env var depending on provider) |
Response: 200 OK
{ "status": "stt_started", "room_id": "room-123", "leg_ids": ["leg-1", "leg-2"] }
Transcripts are delivered via stt.text webhook events.
Errors:
404— Room not found409— STT already running on this room, or room has no participants503— No API key provided for the selected provider
DELETE /v1/rooms/{id}/stt
Stop speech-to-text on a room.
Response: 200 OK
{ "status": "stt_stopped" }
Errors: 404 — No STT in progress
POST /v1/rooms/{id}/agent/elevenlabs
Attach an ElevenLabs ConvAI agent to a room. The agent joins as a virtual participant, hearing all participants (mixed-minus-self) and speaking to everyone.
Request:
{
"agent_id": "abc123",
"first_message": "Hello everyone!",
"language": "en",
"dynamic_variables": { "topic": "meeting" },
"api_key": "xi-..."
}
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | yes | ElevenLabs agent ID |
first_message | string | no | Override the agent's first message |
language | string | no | Language code (e.g. "en", "es") |
dynamic_variables | object | no | Key-value pairs passed to the agent as dynamic variables |
api_key | string | no | API key override (falls back to ELEVENLABS_API_KEY env var) |
Response: 200 OK — { "status": "agent_started", "room_id": "room-123" }
Errors: 400 — Invalid JSON or missing agent_id · 404 — Room not found · 409 — Agent already attached · 503 — No API key
POST /v1/rooms/{id}/agent/vapi
Attach a VAPI agent to a room as a virtual participant.
Request:
{
"assistant_id": "asst_xyz",
"first_message": "Hello everyone!",
"variable_values": { "topic": "meeting" },
"api_key": "vapi-..."
}
| Field | Type | Required | Description |
|---|---|---|---|
assistant_id | string | yes | VAPI assistant ID |
first_message | string | no | Override the agent's first message |
variable_values | object | no | Key-value pairs passed as VAPI variable values |
api_key | string | no | API key override (falls back to VAPI_API_KEY env var) |
Response: 200 OK — { "status": "agent_started", "room_id": "room-123" }
Errors: 400 — Invalid JSON or missing assistant_id · 404 — Room not found · 409 — Agent already attached · 503 — No API key
POST /v1/rooms/{id}/agent/pipecat
Attach a self-hosted Pipecat bot to a room as a virtual participant.
Request:
{
"websocket_url": "ws://my-bot:8765"
}
| Field | Type | Required | Description |
|---|---|---|---|
websocket_url | string | yes | WebSocket URL of the Pipecat bot |
Response: 200 OK — { "status": "agent_started", "room_id": "room-123" }
Errors: 400 — Invalid JSON or missing websocket_url · 404 — Room not found · 409 — Agent already attached
POST /v1/rooms/{id}/agent/deepgram
Attach a Deepgram Voice Agent to a room as a virtual participant.
Request:
{
"settings": {
"agent": {
"listen": { "provider": { "type": "deepgram", "model": "nova-3" } },
"think": { "provider": { "type": "open_ai", "model": "gpt-4o-mini" } },
"speak": { "provider": { "type": "deepgram", "model": "aura-2-asteria-en" } }
}
},
"greeting": "Hello everyone!",
"language": "en",
"api_key": "dg-..."
}
| Field | Type | Required | Description |
|---|---|---|---|
settings | object | no | Full Deepgram agent settings object. When omitted, sensible defaults are used. |
greeting | string | no | Agent greeting message |
language | string | no | Language code (e.g. "en", "es") |
api_key | string | no | API key override (falls back to DEEPGRAM_API_KEY env var) |
Response: 200 OK — { "status": "agent_started", "room_id": "room-123" }
Errors: 400 — Invalid JSON · 404 — Room not found · 409 — Agent already attached · 503 — No API key
POST /v1/rooms/{id}/agent/message
Inject a context message or instruction into a running agent session on a room. This is provider-agnostic — the session routes the message using the appropriate provider mechanism.
Supported providers:
- Deepgram — sends
InjectAgentMessagevia WebSocket - Pipecat — sends a protobuf
TextFramevia WebSocket - VAPI — sends
add-messagevia HTTP control URL - ElevenLabs — not supported (returns
501)
Request:
{
"message": "The customer's name is John and their order number is 12345."
}
| Field | Type | Required | Description |
|---|---|---|---|
message | string | yes | Context or instruction to inject into the running agent session |
Response: 200 OK
{ "status": "message_sent" }
Errors: 400 — Invalid JSON or missing message · 404 — No agent attached to this room · 409 — Agent session not running · 501 — Provider does not support message injection
DELETE /v1/rooms/{id}/agent
Detach the agent from a room (provider-agnostic).
Response: 200 OK
{ "status": "agent_stopped" }
Errors: 404 — No agent attached to this room
GET /v1/rooms/{id}/ws
Upgrade to a WebSocket connection and join the room as a bidirectional audio participant. The client sends and receives 16kHz 16-bit signed little-endian PCM audio (mono), base64-encoded in JSON text frames. Each audio frame is 640 bytes (20ms).
This endpoint shares its WebSocket transport (internal/wsmedia) and wire protocol with GET /v1/legs/websocket when the leg endpoint is invoked with wire_format=json_base64. The two endpoints differ only in semantics: this one attaches a raw mixer participant (no leg lifecycle, no /v1/legs/{id}/... operations, no leg events), while /v1/legs/websocket creates a real leg.
Upgrade: Standard HTTP → WebSocket upgrade. No request body.
Errors:
404— Room not found (returned before upgrade)
Message Format
Server → Client (on connect):
{"type": "connected", "participant_id": "ws-a1b2c3d4", "sample_rate": 16000, "format": "pcm_s16le"}
Client → Server (send audio):
{"audio": "<base64-encoded-16kHz-16bit-PCM>"}
Server → Client (receive mixed audio):
{"audio": "<base64-encoded-16kHz-16bit-PCM>"}
Server → Client (keepalive ping):
{"type": "ping", "event_id": 1}
Client → Server (keepalive pong):
{"type": "pong", "event_id": 1}
Client → Server (disconnect):
{"type": "stop"}
The server sends application-level pings every 30 seconds. The connection is also a full mixer participant — it receives mixed-minus-self audio from all other participants in the room.
GET /v1/vsi (VSI)
Upgrade to a WebSocket connection and receive all events in real-time as JSON text frames. The JSON shape is identical to webhook payloads (same Event.MarshalJSON format).
The full machine-readable contract for the VSI WebSocket — every command, every event, every lifecycle frame — lives in asyncapi.yaml (AsyncAPI 3.0). The tables below are a quick reference; the YAML is authoritative and is generated from internal/api/vsi_meta.go via make asyncapi.
Upgrade: Standard HTTP → WebSocket upgrade. No request body.
Query Parameters:
| Param | Type | Description |
|---|---|---|
app_id | string (regex) | If set, only events whose app_id matches the regex are forwarded. Omit to receive all events. |
Set app_id on legs via POST /v1/legs body, POST /v1/webrtc/offer body (WebRTC legs), or the X-App-ID SIP header on inbound calls. Set on rooms via POST /v1/rooms body. Auto-created rooms inherit app_id from the originating leg.
Events from untagged legs carry an empty app_id and are dropped by any non-empty filter — tag every leg an app cares about, or it will silently miss its own events.
Example with filter:
websocat "ws://localhost:8080/v1/vsi?app_id=^billing$"
Message Format
Server → Client (on connect):
{"type": "connected"}
Server → Client (event):
{"type": "leg.connected", "timestamp": "2026-04-15T12:00:00Z", "instance_id": "i-abc", "leg_id": "550e8400-...", "leg_type": "sip_outbound"}
Events use the same flattened JSON envelope as webhook POSTs. Clients already parsing webhook payloads can reuse the same deserializer.
Server → Client (keepalive ping):
{"type": "ping", "event_id": 1}
Client → Server (keepalive pong):
{"type": "pong"}
Client → Server (disconnect):
{"type": "stop"}
Client → Server (commands):
The WebSocket accepts bidirectional commands using the same naming as the REST API. All commands support an optional request_id echoed back in the response.
// Client sends:
{"type": "mute_leg", "request_id": "req-1", "payload": {"id": "abc-123"}}
// Server responds (success):
{"type": "mute_leg.result", "request_id": "req-1", "data": {"status": "muted"}}
// Server responds (error):
{"type": "error", "request_id": "req-1", "data": {"code": 404, "message": "leg not found"}}
Available commands
| Command | Payload | Description |
|---|---|---|
list_legs | (none) | List all legs |
get_leg | {"id":"..."} | Get a single leg |
create_leg | CreateLegRequest | Originate an outbound leg; returns the leg view. All types are supported (sip, websocket, whatsapp, livekit_room). For livekit_room, custom headers come from the request's headers map. |
delete_leg | {"id":"..."} | Hang up and delete a leg |
answer_leg | {"id":"..."} | Answer a ringing inbound leg |
mute_leg | {"id":"..."} | Mute a leg |
unmute_leg | {"id":"..."} | Unmute a leg |
deaf_leg | {"id":"..."} | Deafen a leg |
undeaf_leg | {"id":"..."} | Undeafen a leg |
hold_leg | {"id":"..."} | Put a SIP leg on hold |
unhold_leg | {"id":"..."} | Resume a held SIP leg |
send_leg_dtmf | {"id":"...","digits":"123"} | Send DTMF digits on a leg |
accept_leg_dtmf | {"id":"..."} | Enable DTMF reception |
reject_leg_dtmf | {"id":"..."} | Disable DTMF reception |
send_leg_rtt | {"id":"...","text":"hello"} | Send Real-Time Text (T.140) on a SIP leg with negotiated m=text |
accept_leg_rtt | {"id":"..."} | Enable RTT reception (default) |
reject_leg_rtt | {"id":"..."} | Disable RTT reception |
webrtc_offer | {"sdp":"..."} | Establish a WebRTC leg via SDP offer/answer; returns {leg_id, sdp} |
webrtc_add_candidate | {"id":"...","candidate":{"candidate":"...","sdpMid":"0","sdpMLineIndex":0}} | Add a remote ICE candidate to a WebRTC leg |
webrtc_get_candidates | {"id":"..."} | Drain server-gathered ICE candidates; returns {candidates, done} |
list_rooms | (none) | List all rooms |
get_room | {"id":"..."} | Get a single room |
create_room | CreateRoomRequest | Create a room |
delete_room | {"id":"..."} | Delete a room |
add_leg_to_room | {"room_id":"...","leg_id":"..."} | Add or move leg to room (supports mute, deaf, accept_dtmf) |
remove_leg_from_room | {"room_id":"...","leg_id":"..."} | Remove leg from room |
The commands below mirror the corresponding REST endpoints and use resource-first naming (leg_*, room_*). All payloads merge the URL identifier with the REST request body fields.
| Command | Payload | Description |
|---|---|---|
leg_ring | {"id":"..."} | Send 180 Ringing on a SIP inbound leg |
leg_early_media | {"id":"...","codec":"PCMU"} | Enable 183 Session Progress with media on a SIP inbound leg |
leg_amd_start | {"id":"...","initial_silence_timeout":2500,...} | Start AMD on a connected SIP leg (all AMDParams fields are optional) |
leg_transfer | {"id":"...","target":"sip:bob@example.com","replaces_leg_id":""} | Initiate SIP REFER transfer (blind or attended) |
leg_record_start | {"id":"...","storage":"file",...} | Start recording a leg (stereo when in a room or SIP, mono otherwise) |
leg_record_stop | {"id":"..."} | Stop a leg recording; returns {status, file} |
leg_record_pause | {"id":"..."} | Pause a leg recording |
leg_record_resume | {"id":"..."} | Resume a paused leg recording |
room_record_start | {"id":"...","multi_channel":true,...} | Start recording a room mix |
room_record_stop | {"id":"..."} | Stop a room recording |
room_record_pause | {"id":"..."} | Pause a room recording |
room_record_resume | {"id":"..."} | Resume a paused room recording |
leg_play_start | {"id":"...","url":"https://...","volume":0} | Start audio playback on a leg; returns {playback_id, status} |
leg_play_stop | {"id":"...","playback_id":"pb-..."} | Stop a leg playback |
leg_play_volume | {"id":"...","playback_id":"pb-...","volume":-3} | Adjust active playback volume (-8..8) |
room_play_start | {"id":"...","tone":"us_ringback"} | Start audio playback into a room mix |
room_play_stop | {"id":"...","playback_id":"pb-..."} | Stop a room playback |
room_play_volume | {"id":"...","playback_id":"pb-...","volume":2} | Adjust active room playback volume |
leg_stt_start | {"id":"...","provider":"deepgram","language":"en"} | Start speech-to-text on a leg |
leg_stt_stop | {"id":"..."} | Stop STT on a leg |
room_stt_start | {"id":"...","provider":"elevenlabs"} | Start STT on every participant of a room (auto-extends to legs that join later) |
room_stt_stop | {"id":"..."} | Stop room STT |
leg_tts | {"id":"...","text":"Hello","voice":"Joanna","provider":"aws"} | Synthesize and play TTS on a leg; returns {tts_id, status} |
room_tts | {"id":"...","text":"...","voice":"..."} | Synthesize and play TTS into a room mix |
leg_agent_elevenlabs | {"id":"...","agent_id":"..."} | Attach an ElevenLabs Conversational AI agent to a leg |
leg_agent_vapi | {"id":"...","assistant_id":"..."} | Attach a VAPI agent to a leg |
leg_agent_pipecat | {"id":"...","websocket_url":"ws://..."} | Attach a Pipecat bot to a leg |
leg_agent_deepgram | {"id":"...","greeting":"...","settings":{...}} | Attach a Deepgram Voice Agent to a leg |
leg_agent_message | {"id":"...","message":"..."} | Inject a text message into a running leg agent session |
leg_agent_stop | {"id":"..."} | Detach the agent from a leg |
room_agent_elevenlabs | {"id":"...","agent_id":"..."} | Attach ElevenLabs agent to a room |
room_agent_vapi | {"id":"...","assistant_id":"..."} | Attach VAPI agent to a room |
room_agent_pipecat | {"id":"...","websocket_url":"ws://..."} | Attach Pipecat bot to a room |
room_agent_deepgram | {"id":"...","greeting":"..."} | Attach Deepgram agent to a room |
room_agent_message | {"id":"...","message":"..."} | Inject a text message into a running room agent session |
room_agent_stop | {"id":"..."} | Detach the agent from a room |
The server sends application-level pings every 30 seconds. If a client reads too slowly, events are buffered per-connection. When the buffer is full, new events are dropped and the server sends a notification before the next successfully delivered event:
{"type": "events_dropped", "count": 12}
On receiving this, the client should resync state via REST (e.g. GET /v1/legs, GET /v1/rooms) since it may have missed transitions.
The per-connection buffer size defaults to 256 events and is configurable via the VSI_EVENT_BUFFER_SIZE environment variable (clamped to [16, 1_000_000]). Operators see a warn log (vsi: event buffer full, dropping event) on the first drop in a burst and on each 10× escalation, so sustained drops are visible without flooding the log.
Tuning the buffer. Larger buffers absorb longer back-pressure spikes but trade off:
- Memory: ~1 KB per slot at typical event sizes; e.g. 256 → ~256 KB per client, 10_000 → ~10 MB per client. Multiply by your concurrent VSI client count.
- Latency: when a slow client catches up, every event in the buffer is delivered before any new one — a 10_000-deep buffer means the client may see events that are tens of seconds old. The 30s ping is unaffected (sent on a separate goroutine), but the application's view of "now" can lag.
- Failure radius: with a small buffer you drop fast and resync fast; with a large buffer the client stays "almost caught up" for longer before giving up.
The default of 256 is sized for healthy clients on a normal event stream (one inbound call generates ~10 events). Increase only when you have a legitimate slow-consumer scenario you can't fix at the client.
Example:
websocat ws://localhost:8080/v1/vsi
WebRTC
POST /v1/webrtc/offer
Establish a WebRTC leg via SDP offer/answer exchange. The browser sends an SDP offer and receives an SDP answer plus a leg ID. The answer is returned immediately without waiting for ICE gathering to complete — use the trickle ICE endpoints below to exchange candidates incrementally.
Request:
{
"sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...",
"app_id": "myapp"
}
| Field | Type | Required | Description |
|---|---|---|---|
sdp | string | yes | SDP offer from the browser. |
app_id | string | no | Tag the leg for event filtering. Carried on every event this leg emits, and matched against the VSI app_id filter. Omit it and the leg's events carry an empty app_id, so any non-empty filter drops them. |
Response: 200 OK
{
"leg_id": "550e8400-e29b-41d4-a716-446655440000",
"sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n..."
}
The returned leg_id can be used with all /v1/legs and /v1/rooms endpoints.
The same request body is accepted by the VSI webrtc_offer command.
leg.connected event: fires only once the underlying peer connection reaches the Connected state (post-ICE/DTLS). Wait for it before pushing media into the leg.
NAT/firewall deployments: when VoiceBlender runs behind NAT (e.g. Docker, a VPC NAT gateway), set WEBRTC_EXTERNAL_IPS to the host's public IPv4/IPv6 address(es) — pion will substitute them into host ICE candidates, allowing remote peers that only emit private host candidates of their own to still reach VB.
Errors:
400— Invalid JSON or invalid SDP offer500— Peer connection, track creation, or answer generation failed
Audio codec: PCMU (G.711 u-law), 8kHz, mono.
POST /v1/legs/{id}/amd
Start answering machine detection on an already-connected SIP leg. This is an alternative to including the amd object in POST /v1/legs — use this endpoint when AMD was not enabled at call creation time.
All AMD parameters are optional. An empty request body {} enables AMD with all defaults. See AMD Parameters above for the full parameter reference, including the total_analysis_time constraints that reject self-defeating params with 400.
Request:
{
"beep_timeout": 10000
}
Response: 200 OK
{
"status": "started"
}
Errors:
400— Invalid AMD params (a negative value on any field, or atotal_analysis_timetoo short to reach any verdict) or leg is not a SIP leg404— Leg not found409— Leg is not inconnectedstate (AMD can only start on answered calls)
POST /v1/legs/{id}/ice-candidates
Send a remote ICE candidate to the server for a WebRTC leg (trickle ICE).
Request:
{
"candidate": "candidate:842163049 1 udp 1677729535 ...",
"sdpMid": "0",
"sdpMLineIndex": 0
}
| Field | Type | Required | Description |
|---|---|---|---|
candidate | string | yes | ICE candidate string |
sdpMid | string | no | Media stream ID |
sdpMLineIndex | integer | no | Media description index |
Response: 200 OK
{ "status": "added" }
Errors:
400— Invalid JSON or leg is not a WebRTC leg404— Leg not found500— Failed to add ICE candidate
GET /v1/legs/{id}/ice-candidates
Retrieve server-side ICE candidates gathered since the last call (trickle ICE). Poll this endpoint until done is true and candidates is empty.
Response: 200 OK
{
"candidates": [
{ "candidate": "candidate:...", "sdpMid": "0", "sdpMLineIndex": 0 }
],
"done": true
}
| Field | Type | Description |
|---|---|---|
candidates | array | ICE candidates gathered since last poll |
done | boolean | true when ICE gathering is complete |
Errors:
400— Leg is not a WebRTC leg404— Leg not found
WebRTC over VSI
The same offer/answer/trickle-ICE flow is also available over the /v1/vsi WebSocket — useful when a client is already connected to receive events and wants to avoid an extra HTTP round trip per ICE candidate. Three commands mirror the REST endpoints:
| Command | Payload | Result |
|---|---|---|
webrtc_offer | {"sdp":"..."} | {"leg_id":"...","sdp":"..."} |
webrtc_add_candidate | {"id":"...","candidate":{...}} | {"status":"added"} |
webrtc_get_candidates | {"id":"..."} | {"candidates":[...],"done":true} |
Example exchange:
// Client → server
{"type":"webrtc_offer","request_id":"r1","payload":{"sdp":"v=0\r\no=- ..."}}
// Server → client
{"type":"webrtc_offer.result","request_id":"r1","data":{"leg_id":"550e8400-...","sdp":"v=0\r\no=- ..."}}
// Client → server (one frame per browser-side candidate)
{"type":"webrtc_add_candidate","request_id":"r2","payload":{"id":"550e8400-...","candidate":{"candidate":"candidate:...","sdpMid":"0","sdpMLineIndex":0}}}
// Server → client
{"type":"webrtc_add_candidate.result","request_id":"r2","data":{"status":"added"}}
// Client polls until done=true
{"type":"webrtc_get_candidates","request_id":"r3","payload":{"id":"550e8400-..."}}
{"type":"webrtc_get_candidates.result","request_id":"r3","data":{"candidates":[{"candidate":"candidate:...","sdpMid":"0","sdpMLineIndex":0}],"done":false}}
The returned leg_id is interchangeable with REST: subsequent mute_leg, add_leg_to_room, delete_leg, etc. all accept it. Errors follow the standard VSI error envelope ({"type":"error","request_id":"...","data":{"code":...,"message":"..."}}).
Webhooks
Webhooks deliver real-time event notifications via HTTP POST. There are three ways to configure webhooks:
- Global webhook — set via
WEBHOOK_URLandWEBHOOK_SECRETenvironment variables. Receives all events that don't have a more specific webhook. - Per-leg webhook — set via
webhook_url/webhook_secretin the create leg request body, or viaX-Webhook-URL/X-Webhook-SecretSIP headers on inbound calls. - Per-room webhook — set via
webhook_url/webhook_secretin the create room request body.
Routing Priority
When an event is emitted, webhooks are resolved in this order (highest to lowest):
- Leg's webhook — used when the event carries a
leg_idand that leg has awebhook_urlset. - Room's webhook — used when the event has a
room_id(but no matching leg webhook) and that room has awebhook_urlset. - Global webhook — used for all other events (configured via
WEBHOOK_URLenv var).
For events that carry both leg_id and room_id (e.g. speaking.started, stt.text), the leg's webhook takes precedence over the room's webhook.
For inbound SIP calls, the X-Webhook-URL and X-Webhook-Secret SIP headers in the INVITE can set per-leg webhooks on a call-by-call basis, overriding the WEBHOOK_URL environment variable.
Webhook Events
Events are delivered as HTTP POST requests to registered webhook URLs.
Delivery
- Method: POST
- Content-Type:
application/json - Retries: 3 attempts with exponential backoff (2s, 4s)
- Timeout: 10 seconds per attempt
- Worker pool: 10 concurrent delivery goroutines
- Queue capacity: 1000 events (dropped if full)
Signature Verification
When a secret is configured, a X-Signature-256 header is included:
X-Signature-256: sha256=<hex-encoded-hmac-sha256>
The signature is computed over the raw JSON request body using HMAC-SHA256 with the webhook secret as the key.
Event Envelope
Event data fields are flattened into the top-level JSON object alongside the envelope fields — there is no "data" wrapper.
{
"type": "leg.ringing",
"timestamp": "2026-03-01T11:05:00.123Z",
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"leg_id": "550e8400-e29b-41d4-a716-446655440000",
"leg_type": "sip_inbound",
"from": "sip:alice@example.com",
"to": "sip:bob@example.com",
"offered_codecs": [
{ "name": "opus", "payload_type": 111, "clock_rate": 48000, "priority": 1 },
{ "name": "PCMU", "payload_type": 0, "clock_rate": 8000, "priority": 2 },
{ "name": "PCMA", "payload_type": 8, "clock_rate": 8000, "priority": 3 }
]
}
offered_codecs (inbound SIP only) lists the audio codecs from the remote INVITE's offer SDP, in offer order. priority is 1-based and matches the order — lower value = higher priority. Use any name from this list as the codec field on POST /v1/legs/{id}/early-media or POST /v1/legs/{id}/answer to force that codec for the answer SDP.
source_address (inbound SIP only) is the transport-layer host:port the INVITE arrived on. Use it to decide whether to challenge the call via POST /v1/legs/{id}/challenge.
authenticated / auth_username (inbound SIP only) are present and set when the INVITE carried digest credentials that VoiceBlender verified against a prior /challenge — i.e. the credentialed retry surfaced as a new, authenticated leg. They are omitted for un-challenged calls.
All events include instance_id alongside the event-specific fields.
Event Types
All event data uses typed structs with consistent field names. Events scoped to a leg include leg_id, events scoped to a room include room_id, and events that can target either include both (with the unused field omitted).
| Event | Description | Data Fields |
|---|---|---|
leg.ringing | SIP or WhatsApp call ringing | leg_id, leg_type (sip_inbound/sip_outbound/whatsapp_in), from, to (inbound); leg_id, leg_type, uri, from (outbound). sip_headers included when X-* headers are present. offered_codecs included on inbound SIP — array of {name, payload_type, clock_rate, priority} from the remote SDP offer, in priority order. |
leg.early_media | Outbound leg received 183 Session Progress with SDP; media pipeline active | leg_id, leg_type |
leg.connected | Leg answered/connected | leg_id, leg_type |
leg.disconnected | Leg hung up | leg_id, cdr, quality (see CDR-style structure below) |
leg.joined_room | Leg added to room | leg_id, room_id |
leg.left_room | Leg removed from room | leg_id, room_id |
leg.muted | Leg muted | leg_id |
leg.unmuted | Leg unmuted | leg_id |
leg.deaf | Leg deafened | leg_id |
leg.undeaf | Leg undeafened | leg_id |
leg.hold | Leg put on hold (local or remote) | leg_id, leg_type |
leg.unhold | Leg taken off hold (local or remote) | leg_id, leg_type |
leg.command_failed | An asynchronous leg command failed after the HTTP 202 was returned | leg_id, command (e.g. ring, early_media, hold, unhold, add_to_room), error |
leg.transfer_initiated | We sent a SIP REFER for this leg | leg_id, kind (blind/attended), target, replaces_leg_id |
leg.transfer_requested | A peer sent us a SIP REFER targeting this leg. In the default app-driven model it is a decision request — respond via accept_transfer/decline_transfer (see Receiving a transfer). declined is vestigial (always false) | leg_id, kind, target, replaces_call_id, declined |
leg.transfer_progress | NOTIFY sipfrag for an in-flight transfer | leg_id, status_code, reason |
leg.transfer_completed | Transfer reached terminal 2xx; leg is hung up | leg_id, status_code, reason |
leg.transfer_failed | Transfer ended in non-2xx or local error | leg_id, status_code, reason, error |
dtmf.received | DTMF digit received | leg_id, digit, seq |
rtt.received | RTT (T.140 / RFC 4103) text chunk received | leg_id, text, seq, loss_marker |
speaking.started | Participant started speaking | leg_id, room_id (if in a room) |
speaking.stopped | Participant stopped speaking | leg_id, room_id (if in a room) |
Note:
speaking.startedandspeaking.stoppedevents fire for any connected leg, whether standalone or in a room. When the leg is in a room, the event includesroom_id; standalone legs omit it.Opt-in: Speech detection is disabled by default. Enable it globally by setting
SPEECH_DETECTION_ENABLED=true, or per call by setting"speech_detection": trueonPOST /v1/legs(outbound) orPOST /v1/legs/{id}/answer(inbound). Per-call values override the global default.
| playback.started | Playback began | leg_id or room_id, playback_id |
| playback.finished | Playback ended | leg_id or room_id, playback_id, reason, played_ms |
| playback.error | Playback failed | leg_id or room_id, playback_id, error |
| tts.started | TTS synthesis began playing | leg_id or room_id, tts_id |
| tts.finished | TTS synthesis finished playing | leg_id or room_id, tts_id, reason, played_ms |
| tts.error | TTS synthesis or playback failed | leg_id or room_id, tts_id, error |
Note:
playback.finishedandtts.finishedcarryreasonandplayed_msso you can tell whether a prompt was heard in full or was cut short, and by how much.
reasoniscompletedwhen the audio played through to its end, andstoppedwhen it did not reach the end — for any reason. That includes an app-initiated stop, a barge-in, and a leg hanging up: all three cancel the same playback, and they cannot be told apart fromreasonalone. To distinguish a hangup from a deliberate stop, look for a co-emittedleg.disconnectedevent. Tone playback never ends on its own, so it always reportsstopped.
played_msis how much audio was actually written to the leg or room, in milliseconds. It counts audio played, not the source file's duration: arepeated playback accumulates across every iteration, soplayed_mscan exceed the length of the file.
| recording.started | Recording began | leg_id or room_id, file |
| recording.finished | Recording ended | leg_id or room_id, file, multi_channel_file, channels (multi-channel only) |
| recording.paused | Recording paused (audio replaced with silence) | leg_id or room_id |
| recording.resumed | Recording resumed from a paused state | leg_id or room_id |
| stt.text | Speech-to-text transcript | leg_id, room_id (if room STT), text, is_final |
| agent.connected | Agent connected to provider | leg_id or room_id, conversation_id |
| agent.disconnected | Agent session ended | leg_id or room_id |
| agent.user_transcript | User speech transcribed by agent | leg_id or room_id, text |
| agent.agent_response | Agent generated a response | leg_id or room_id, text |
| room.created | Room created | room_id |
| room.deleted | Room deleted | room_id |
| room.bridged | Two rooms' mixers joined | bridge_id, room_a_id, room_b_id, direction |
| room.bridge_updated | Bridge direction changed | bridge_id, room_a_id, room_b_id, direction |
| room.unbridged | Bridge torn down | bridge_id, room_a_id, room_b_id, reason |
| amd.result | Answering machine detection completed | leg_id, result, initial_silence_ms, greeting_duration_ms, total_analysis_ms |
| amd.beep | Voicemail beep tone detected | leg_id, beep_ms |
| sip.registration_attempt | An inbound REGISTER (that would create/remove a binding) is awaiting a challenge/accept/reject decision; after SIP_INBOUND_AUTH_CONSULT_TIMEOUT_MS the SIP_INBOUND_REGISTER_DEFAULT fallback applies (reject by default) | attempt_id, aor, contact, source_address, transport, user_agent, call_id, has_authorization |
| sip.registration_active | A SIP AOR binding was created or refreshed | aor, contact, socket, transport, user_agent, call_id, granted_expires_seconds, expires_at |
| sip.registration_expired | A SIP AOR binding was removed | aor, contact, socket, reason (ttl / unregistered / forced / replaced) |
LiveKit participants. Remote LK participants do not get their own special event types. Each appears as a regular
leg.connected/leg.disconnectedfor alivekit_participantleg (Model B).speaking.started/speaking.stoppedapply per-leg as usual. Theleg.disconnected.reasonfor an LK participant leg islivekit_participant_left.
amd.result — Answering Machine Detection
Emitted when AMD analysis completes on an outbound call. The result field is one of:
human— Short greeting followed by silence (likely a person).machine— Long greeting (likely voicemail or IVR).no_speech— No speech detected within the initial silence timeout.not_sure— Analysis timed out without a confident determination.
{
"type": "amd.result",
"timestamp": "2026-04-01T12:00:00Z",
"instance_id": "abc-123",
"leg_id": "leg-456",
"result": "machine",
"initial_silence_ms": 120,
"greeting_duration_ms": 1680,
"total_analysis_ms": 1800
}
When beep_timeout is set and the result is machine, the amd.result event is sent immediately, then the analyzer continues listening for the voicemail beep tone (800–1200 Hz). If detected, a separate amd.beep event is emitted:
{
"type": "amd.beep",
"timestamp": "2026-04-01T12:00:03Z",
"instance_id": "abc-123",
"leg_id": "leg-456",
"beep_ms": 1400
}
The beep_ms field is the time from machine detection to beep detection. Use this event to know exactly when to start playing your voicemail message.
leg.disconnected — CDR-Style Structure
The leg.disconnected event uses a cdr object for disconnect reason and timing, plus an optional quality object for RTP metrics.
Answered call with quality metrics:
{
"type": "leg.disconnected",
"timestamp": "2026-03-24T14:30:00.123Z",
"instance_id": "inst-abc",
"leg_id": "550e8400-e29b-41d4-a716-446655440000",
"cdr": {
"reason": "remote_bye",
"duration_total": 125.43,
"duration_answered": 120.10
},
"quality": {
"mos_score": 4.21,
"rtp_packets_received": 6025,
"rtp_packets_lost": 12,
"rtp_jitter_ms": 3.45
}
}
Unanswered call (no quality):
{
"type": "leg.disconnected",
"timestamp": "2026-03-24T14:30:08.650Z",
"instance_id": "inst-abc",
"leg_id": "550e8400-e29b-41d4-a716-446655440000",
"cdr": {
"reason": "caller_cancel",
"duration_total": 8.52,
"duration_answered": 0
}
}
cdr Object
| Field | Type | Description |
|---|---|---|
reason | string | See Disconnect Reasons below |
duration_total | float | Seconds from leg creation (INVITE sent/received) to disconnect |
duration_answered | float | Seconds from answer (200 OK) to disconnect. 0 if the leg was never answered. |
quality Object (omitted when no media was received)
| Field | Type | Description |
|---|---|---|
mos_score | float | Mean Opinion Score (1.0–5.0) estimated via simplified E-model (ITU-T G.107) from packet loss and jitter |
rtp_packets_received | integer | Total inbound RTP audio packets received |
rtp_packets_lost | integer | Estimated lost packets based on sequence number gaps |
rtp_jitter_ms | float | Inter-arrival jitter in milliseconds (RFC 3550 §A.8) |
Disconnect Reasons:
| Reason | Description |
|---|---|
api_hangup | Hung up via DELETE /v1/legs/{id} |
remote_bye | Remote party sent BYE |
caller_cancel | Inbound caller hung up before answer |
ring_timeout | Outbound ring_timeout expired before answer |
max_duration | Outbound max_duration reached after connect |
busy | Remote returned 486 Busy Here |
unavailable | Remote returned 480 Temporarily Unavailable |
not_found | Remote returned 404 Not Found |
forbidden | Remote returned 403 Forbidden |
unauthorized | Remote returned 401/407 Authentication Required |
timeout | Remote returned 408 Request Timeout |
cancelled | INVITE was cancelled (487 Request Terminated) |
not_acceptable | Remote returned 488 Not Acceptable Here |
service_unavailable | Remote returned 503 Service Unavailable |
declined | Remote returned 603 Decline |
sip_{code} | Other SIP failure response (e.g. sip_500) |
rtp_timeout | No RTP packets received for 30 seconds |
session_expired | SIP session timer expired without refresh (RFC 4028) |
invite_failed | INVITE failed for a non-SIP reason (transport error, DNS failure, etc.) |
connect_failed | Call answered but media/codec negotiation failed |
ice_failure | WebRTC ICE connection failed |
room_deleted | Leg was in a room that was deleted via DELETE /v1/rooms/{id} |
transfer_completed | Leg ended because a transfer it initiated reached terminal 2xx |
rejected | Inbound leg rejected by API via DELETE /v1/legs/{id} with reason (also see other reason values from the rejection mapping table) |
mixer_panic | The leg's audio path failed inside the mixer and the leg was torn down. The leg had already left its room (leg.left_room); it is deaf and mute at this point, so it is hung up rather than left connected. Its room, and any other legs in it, are unaffected |
Error Format
All errors return:
{ "error": "description of what went wrong" }
Configuration
| Environment Variable | Default | Description |
|---|---|---|
INSTANCE_ID | (auto-generated UUID) | Instance identifier, included in all API response bodies and webhook events |
HTTP_ADDR | :8080 | REST API listen address |
ALLOWED_IPS | (empty = allow all) | Comma-separated allowlist of IPs and CIDR ranges (IPv4 and IPv6, in any mix) gating every HTTP endpoint, including the /v1/vsi event WebSocket, /v1/legs/websocket, the /v1/legs/moq WebTransport endpoint, /metrics, and pprof. Bare addresses are treated as host routes (/32 or /128); malformed entries fail server startup. Only X-Forwarded-For is consulted as a proxy header (see TRUST_PROXY_HEADERS). Examples: 127.0.0.1, 10.0.0.0/8,192.168.0.0/16, 2001:db8::/32,::1. |
TRUST_PROXY_HEADERS | false | When true, the client IP used for the ALLOWED_IPS check is taken from the leftmost entry in X-Forwarded-For (falling back to the socket peer when the header is absent). Default false ignores X-Forwarded-For. Enable only behind a trusted reverse proxy that unconditionally overwrites the header. |
SIP_BIND_IP | 127.0.0.1 | IP advertised in SDP/Contact/Via headers (auto-detected if 0.0.0.0) |
SIP_LISTEN_IP | (same as SIP_BIND_IP) | IP to bind the UDP socket on |
SIP_PORT | 5060 | SIP listen port |
SIP_HOST | voiceblender | SIP User-Agent name |
ICE_SERVERS | stun:stun.l.google.com:19302 | STUN/TURN URLs (comma-separated) |
RECORDING_DIR | /tmp/recordings | Recording output directory |
LOG_LEVEL | info | Log level (debug, info, warn, error) |
WEBHOOK_URL | (none) | Global webhook URL. Events without a per-leg or per-room webhook are delivered here. |
WEBHOOK_SECRET | (none) | HMAC-SHA256 signing secret for the global webhook. |
ELEVENLABS_API_KEY | (none) | Default ElevenLabs API key for TTS, STT, and Agent features (can be overridden per-request via api_key in the request body) |
VAPI_API_KEY | (none) | Default VAPI API key for Agent features when provider=vapi (can be overridden per-request via api_key in the request body) |
DEEPGRAM_API_KEY | (none) | Default Deepgram API key for STT, TTS, and Agent features when provider=deepgram (can be overridden per-request via api_key in the request body) |
S3_BUCKET | (none) | S3 bucket name (required for storage=s3 recordings) |
S3_REGION | us-east-1 | AWS region for S3 |
S3_ENDPOINT | (none) | Custom S3 endpoint for S3-compatible stores (MinIO, etc.) |
S3_PREFIX | (none) | Key prefix for S3 objects (e.g. recordings/) |
TTS_CACHE_ENABLED | false | Enable disk-backed TTS audio cache. Cached audio is stored on disk and persists across restarts. |
TTS_CACHE_DIR | /tmp/tts_cache | Directory for cached TTS audio files. |
TTS_CACHE_INCLUDE_API_KEY | false | Include API key in TTS cache key (set true if different keys map to different voice clones) |
SIP Session Timers (RFC 4028)
- Accepts session timers requested by the remote UA (inbound and outbound)
- Minimum session interval: 90 seconds (
Min-SE) - Supports both
refresher=uacandrefresher=uasroles - Re-INVITEs (including hold/unhold) reset the session timer
- Expired sessions disconnect with reason
session_expired
SIP Registrations (AOR)
VoiceBlender accepts inbound SIP REGISTER requests on UDP, TCP, and TLS
(the latter on SIP_TLS_PORT). All REGISTERs are auto-approved —
authentication is expected to be performed by a SIP proxy in front of
VoiceBlender. The registrar maintains an in-memory map of canonicalised
AORs to one or more bound contacts, each keyed by the exact transport
socket the REGISTER arrived on. Bindings expire automatically when their
TTL elapses (Expires header / ;expires= Contact param, clamped to
the configured maximum).
Registration lifecycle
| Step | What happens | Event emitted |
|---|---|---|
REGISTER with non-zero expires | Binding added/refreshed; 200 OK is returned with the granted expiry | sip.registration_active |
REGISTER with Contact: * and Expires: 0 | Every contact under the AOR is removed | sip.registration_expired (reason: unregistered) |
REGISTER with expires=0 on a specific Contact | That single contact is removed | sip.registration_expired (reason: unregistered) |
| TTL elapses | Sweeper removes the binding | sip.registration_expired (reason: ttl) |
DELETE /v1/sip/registrations/{aor} | Operator force-unbinds the AOR (or a single contact via ?contact=) | sip.registration_expired (reason: forced) |
New REGISTER while SIP_REGISTRATION_ALLOW_MULTIPLE_CONTACTS=false | Prior Contacts under the AOR are displaced | sip.registration_expired (reason: replaced) |
When SIP_REGISTRATION_ALLOW_MULTIPLE_CONTACTS=true (default), the same
AOR can be registered from multiple Contacts simultaneously (e.g. a user
running a softphone on desktop and a SIP client on mobile). Each
Contact's binding has its own socket and expires_at.
Dialing a registered AOR
POST /v1/legs looks up the value of the to (or legacy uri) field
in the registrar. When a match is found, the outbound INVITE is sent to
the binding's transport socket — bypassing the URI's host:port — and
reusing the persistent TCP/TLS connection from the original REGISTER
where applicable. No change to the API call shape:
curl -X POST http://vb.local:8080/v1/legs \
-H "Content-Type: application/json" \
-d '{"type":"sip","to":"sip:alice@vb.example","from":"support"}'
When the AOR has multiple bound contacts, the INVITE is parallel-forked: every Contact rings simultaneously, the first to answer (2xx) wins, and the other branches are CANCELled (RFC 3261 §16). The leg's lifecycle events report the winning branch only.
When the to value does not match any AOR, the leg is routed via the
URI's host:port exactly as before.
GET /v1/sip/registrations
List every currently bound AOR contact.
curl http://vb.local:8080/v1/sip/registrations
{
"instance_id": "abc-123",
"bindings": [
{
"aor": "sip:alice@vb.example",
"contact": "sip:alice@10.0.0.5:5060",
"socket": "203.0.113.7:51020",
"transport": "udp",
"user_agent": "PJSUA/2.13",
"call_id": "c8f6...",
"created_at": "2026-06-01T11:30:00Z",
"last_refresh": "2026-06-01T11:45:00Z",
"expires_at": "2026-06-01T12:15:00Z",
"granted_expires_seconds": 1800
}
]
}
DELETE /v1/sip/registrations/{aor}
Force-unbind every contact under an AOR. The AOR must be URL-encoded in the path.
curl -X DELETE "http://vb.local:8080/v1/sip/registrations/sip%3Aalice%40vb.example"
Add ?contact=<contact-uri> to remove only one Contact (the contact URI
must also be URL-encoded).
Responses: 204 No Content on success; 404 Not Found when the AOR (or
the specified contact) does not exist.
VSI commands
The same listing is available over the VSI WebSocket:
{"type": "list_sip_registrations", "request_id": "1"}
Server replies:
{"type": "list_sip_registrations.result", "request_id": "1",
"data": {"bindings": [...]}}
Force-unbind is available as delete_sip_registration. The AOR is sent plain
(no URL-encoding, unlike the REST path); add contact to remove just one
Contact:
{"type": "delete_sip_registration", "request_id": "2",
"payload": {"aor": "sip:alice@vb.example"}}
Server replies {"type": "delete_sip_registration.result", "request_id": "2", "data": {"status": "unbound"}}, or an error frame with code 404 when the
AOR (or contact) does not exist.
The sip.registration_active / sip.registration_expired events flow
through the standard webhook and VSI channels — see Event Types above.
Inbound REGISTER authentication (digest challenge)
Inbound REGISTER auth is handled symmetrically with inbound INVITE: just as
an INVITE always surfaces leg.ringing and waits for the client to decide,
every inbound REGISTER that creates or removes a binding is surfaced for a
decision and may be challenged.
Flow:
- An inbound REGISTER (with a Contact) arrives. VoiceBlender publishes a
sip.registration_attemptevent (carryingattempt_id,aor,source_address,transport, etc.) and parks the REGISTER awaiting a decision for up toSIP_INBOUND_AUTH_CONSULT_TIMEOUT_MS(default 2000 ms). - The client responds by
attempt_id:- challenge — VoiceBlender replies
401with aWWW-Authenticatedigest challenge. - accept — bind and reply
200 OK. - reject — reply
403(or a custom code/reason).
- challenge — VoiceBlender replies
- If challenged, the UA retries the REGISTER with an
Authorizationheader (sameCall-ID). VoiceBlender verifies the digest against the supplied credential: on success it binds and replies200 OK; on failure it replies403 Forbidden.
Unlike an INVITE — whose leg can ring indefinitely — a REGISTER is request/response
and cannot be parked forever, so the consult is bounded by the timeout above. If
no decision arrives within that window the SIP_INBOUND_REGISTER_DEFAULT
fallback applies: reject (403) by default (fail-closed — an unanswered
REGISTER is denied), or accept to bind it (the legacy fail-open behaviour,
appropriate when a front proxy enforces auth).
POST /v1/sip/registrations/attempts/{id}/challenge # body: ChallengeRequest (see POST /v1/legs/{id}/challenge)
POST /v1/sip/registrations/attempts/{id}/accept # optional body: { "max_expires": 30 }
POST /v1/sip/registrations/attempts/{id}/reject # optional body: { "code": 403, "reason": "Forbidden" }
All three return 202 Accepted on success, or 404 when the attempt is
unknown or already decided (e.g. the consult timeout already elapsed). The same
actions are available over VSI as challenge_registration,
accept_registration, and reject_registration (payload { "id": "<attempt_id>", ... }).
The ChallengeRequest body matches POST /v1/legs/{id}/challenge: realm
(required), one of password / ha1, and optional username, algorithm,
qop. Supplied credentials are held only in memory for the challenge's lifetime
(SIP_INBOUND_AUTH_NONCE_TTL_SECONDS) and never persisted or returned.
Capping the granted TTL (max_expires). Both the challenge and accept
bodies accept an optional max_expires (seconds). It caps the binding lifetime
granted for that REGISTER: the effective grant is
min(clamped_requested_expires, max_expires), so a UA asking for less still
wins and the value never exceeds SIP_REGISTRATION_MAX_EXPIRES_SECONDS. The
registrar's 60 s floor still applies — a max_expires below 60 grants 60.
On a challenge the cap is remembered and applied when the credentialed
re-REGISTER binds. Omit it (or send 0) to leave the registrar's normal clamp
in force; a negative value is rejected with 400. This lets you force
short-lived registrations — and, when challenging, frequent re-authentication —
without lowering the global clamp.
// Challenge this REGISTER and, once authenticated, grant only a 90 s binding
POST /v1/sip/registrations/attempts/{attempt_id}/challenge
{ "realm": "vb.example", "username": "alice", "password": "s3cret", "max_expires": 90 }
// Accept immediately but cap the binding at 60 s (the minimum)
POST /v1/sip/registrations/attempts/{attempt_id}/accept
{ "max_expires": 60 }
Configuration
| Variable | Default | Description |
|---|---|---|
SIP_REGISTRATION_DEFAULT_EXPIRES_SECONDS | 3600 | Used when the REGISTER carries no Expires value |
SIP_REGISTRATION_MAX_EXPIRES_SECONDS | 7200 | Upper clamp on the granted expiry |
SIP_REGISTRATION_SWEEP_INTERVAL_MS | 1000 | How often the expiry sweeper runs |
SIP_REGISTRATION_ALLOW_MULTIPLE_CONTACTS | true | When false, every REGISTER replaces any prior Contacts for the AOR |
SIP_INBOUND_AUTH_CONSULT_TIMEOUT_MS | 2000 | How long an inbound REGISTER is parked awaiting a challenge/accept/reject decision before the fallback applies |
SIP_INBOUND_REGISTER_DEFAULT | reject | Fallback for an undecided inbound REGISTER: reject (403, fail-closed default) or accept (bind, legacy fail-open) |
SIP_INBOUND_AUTH_NONCE_TTL_SECONDS | 60 | Lifetime of an issued inbound-auth challenge nonce |
SIP Trunks (Outbound Registrations)
VoiceBlender acts as a SIP UAC and REGISTERs to an upstream SIP
registrar/PBX so the registrar can deliver inbound calls to it and so that
VoiceBlender's outbound calls traverse the registrar's proxy under the
registered identity. Trunks are a typed resource: only the sip_register
type is implemented in this release; ip_ip (static-IP peering) is reserved
in the API schema and returns 501 Not Implemented when requested.
Lifecycle summary
| Action | Trigger | Event published |
|---|---|---|
| Create trunk → first REGISTER succeeds | POST /v1/sip/trunks | sip.outbound_registration_active |
| Periodic refresh succeeds | timer fires at granted_expires * SIP_OUTBOUND_REGISTRATION_REFRESH_RATIO | sip.outbound_registration_active (re-emitted for liveness) |
| Transport error or non-2xx response | REGISTER attempt fails (after digest retry) | sip.outbound_registration_failed |
| Upstream binding lapsed while still failing | granted lifetime expires and refresh has not recovered | sip.outbound_registration_expired (reason: refresh_failed) — emitted once per outage; resets on the next successful REGISTER |
DELETE /v1/sip/trunks/{id} | operator removes the trunk | sip.outbound_registration_expired (reason: unregistered) |
| Server shutdown | every trunk is unregistered in parallel | sip.outbound_registration_expired (reason: unregistered or shutdown) |
Implicit call wiring
- Outbound:
POST /v1/legswithfromequal to a registered trunk's AOR (full URI likesip:alice@pbx.example) or just the user-part (alice) auto-attaches the trunk's digest credentials and adds a loose-routeRoute: <trunk's registrar URI;lr>header. Caller-suppliedauthalways wins. The resulting leg'sleg.ringingevent carriestrunk_id. - Inbound: any INVITE whose source socket matches a trunk's upstream
registrar peer (full host:port, or host-only as a fallback for ephemeral
source ports) is tagged with
trunk_idon theleg.ringingevent. No filtering — calls from unknown peers still ring as before.
POST /v1/sip/trunks
Create and start a trunk. Synchronously validates; REGISTER runs
asynchronously. Returns 202 Accepted with {id, type, status}.
curl -X POST http://vb.local:8080/v1/sip/trunks \
-H "Content-Type: application/json" \
-d '{
"type": "sip_register",
"app_id": "acme",
"sip_register": {
"registrar_uri": "sip:pbx.example.com:5060",
"aor": "sip:alice@pbx.example.com",
"username": "alice",
"password": "supersecret",
"contact_user": "alice",
"expires_seconds": 600
}
}'
Response:
{
"instance_id": "abc-123",
"id": "7f5d39c6-2987-4643-9822-5c7ced9080e7",
"type": "sip_register",
"status": "registering"
}
Field reference (sip_register block):
| Field | Required | Description |
|---|---|---|
registrar_uri | yes | Upstream registrar SIP URI. sips: / ;transport=tls switches to TLS. |
aor | yes | Address-of-record. Becomes the From URI and the implicit-match key for outbound calls. |
username | no | Digest username. Defaults to the AOR user-part. |
password | yes | Digest password. Never returned in any response. |
contact_user | no | Override the Contact user-part. Defaults to the AOR user-part. |
expires_seconds | no | Requested expiry. Clamped to [SIP_OUTBOUND_REGISTRATION_MIN_EXPIRES_SECONDS, SIP_OUTBOUND_REGISTRATION_MAX_EXPIRES_SECONDS]. |
Errors: 400 for invalid JSON, missing fields, or invalid URIs. 501 when
type == "ip_ip" (not yet implemented). 400 for unknown types.
GET /v1/sip/trunks
List every configured trunk.
curl http://vb.local:8080/v1/sip/trunks
{
"instance_id": "abc-123",
"trunks": [
{
"id": "7f5d39c6-2987-4643-9822-5c7ced9080e7",
"type": "sip_register",
"app_id": "acme",
"status": "active",
"created_at": "2026-06-24T12:00:00Z",
"sip_register": {
"registrar_uri": "sip:pbx.example.com:5060",
"aor": "sip:alice@pbx.example.com",
"username": "alice",
"contact_uri": "sip:alice@vb.example:5060",
"requested_expires_seconds": 600,
"granted_expires_seconds": 300,
"last_registered_at": "2026-06-24T12:00:01Z",
"next_refresh_at": "2026-06-24T12:02:31Z",
"call_id": "f7c1...@vb.example",
"cseq": 4
}
}
]
}
GET /v1/sip/trunks/{id}
Return the same view shape for a single trunk. 404 Not Found if the id
is unknown.
DELETE /v1/sip/trunks/{id}
Returns 202 Accepted immediately. In the background: cancels the refresh
timer, sends one final REGISTER with Expires: 0 (digest-authed if
challenged), removes the trunk from the manager, and emits
sip.outbound_registration_expired with reason: unregistered.
curl -X DELETE http://vb.local:8080/v1/sip/trunks/7f5d39c6-2987-4643-9822-5c7ced9080e7
Events
| Event | When |
|---|---|
sip.outbound_registration_active | REGISTER (initial or refresh) returned 2xx. Carries trunk_id, aor, registrar, contact, granted_expires_seconds, expires_at, call_id. |
sip.outbound_registration_failed | REGISTER attempt failed (transport error, non-2xx after digest retry). Carries trunk_id, aor, registrar, status_code, reason. The trunk stays in the manager and retries with exponential backoff. |
sip.outbound_registration_expired | Trunk removed (DELETE or shutdown), or refresh failed past granted lifetime. reason is one of unregistered, shutdown, refresh_failed. The refresh_failed variant fires once per outage and resets on the next successful REGISTER. |
VSI commands
The same four operations are available on the /v1/vsi WebSocket:
create_sip_trunk, list_sip_trunks, get_sip_trunk, delete_sip_trunk.
Payloads and result shapes mirror the REST endpoints above.
Configuration
| Variable | Default | Description |
|---|---|---|
SIP_OUTBOUND_REGISTRATION_DEFAULT_EXPIRES_SECONDS | 3600 | Default requested expiry when expires_seconds is omitted on create |
SIP_OUTBOUND_REGISTRATION_MIN_EXPIRES_SECONDS | 60 | Lower clamp on requested expiry |
SIP_OUTBOUND_REGISTRATION_MAX_EXPIRES_SECONDS | 7200 | Upper clamp on requested expiry |
SIP_OUTBOUND_REGISTRATION_REFRESH_RATIO | 0.5 | Fraction of granted expiry at which the trunk refreshes |
SIP_OUTBOUND_REGISTRATION_FAILURE_BACKOFF_MAX_MS | 300000 | Upper cap on the failure-retry exponential backoff |
Typical Workflow
1. Configure global webhook via WEBHOOK_URL env var, or per-leg via request/SIP headers
2. Receive inbound call -> webhook: leg.ringing {leg_id, leg_type: "sip_inbound", from, to}
3. Answer the call
POST /v1/legs/{leg_id}/answer
4. Attach an AI agent to the leg
POST /v1/legs/{leg_id}/agent {"agent_id": "your-agent-id"}
5. Agent converses with the caller. Webhooks deliver:
- agent.connected {leg_id, conversation_id}
- agent.user_transcript {leg_id, text}
- agent.agent_response {leg_id, text}
6. Or: create a room for multi-party conferencing
POST /v1/rooms {"id": "conference-1"}
7. Add legs to the room
POST /v1/rooms/conference-1/legs {"leg_id": "..."}
8. Originate a second call and add to room
POST /v1/legs {"type": "sip", "uri": "sip:bob@10.0.0.1", "codecs": ["PCMU"]}
POST /v1/rooms/conference-1/legs {"leg_id": "..."}
9. Attach a room-level agent (hears everyone, speaks to everyone)
POST /v1/rooms/conference-1/agent {"agent_id": "your-agent-id"}
10. Start recording
POST /v1/rooms/conference-1/record
11. Play announcement
POST /v1/rooms/conference-1/play {"url": "...", "mime_type": "audio/wav"}
12. Cleanup
DELETE /v1/rooms/conference-1
Metrics
GET /metrics
Returns Prometheus-format metrics for the VoiceBlender instance. No request body or authentication is required.
Response: 200 OK — Prometheus text exposition format (text/plain; version=0.0.4)
Exported Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
voiceblender_active_legs | Gauge | — | Number of legs currently in any state (ringing, early_media, connected, held) |
voiceblender_active_rooms | Gauge | — | Number of rooms currently open |
voiceblender_legs_total | Counter | type, state | Total leg lifecycle transitions. type: sip_inbound, sip_outbound. state: ringing, connected, disconnected |
voiceblender_disconnect_reasons_total | Counter | type, reason | Total disconnected legs by type and reason (e.g. remote_bye, api_hangup, rtp_timeout) |
voiceblender_call_duration_seconds | Histogram | type | Answered call duration (time from answer to hangup). Use rate(sum)/rate(count) for ACD |
voiceblender_call_total_duration_seconds | Histogram | type | Total leg lifetime including ringing time (time from leg creation to hangup) |
voiceblender_recovered_panics_total | Counter | component, site | Panics recovered and contained instead of crashing the process. component: mixer, room. site: readLoop, writeLoop, mixTick, panicTeardown, deleteHangup |
| Go runtime metrics | — | — | Standard go_* and process_* metrics from the Prometheus Go client |
PromQL Examples
Compute the Average Call Duration (ACD) over a 5-minute window:
rate(voiceblender_call_duration_seconds_sum[5m])
/ rate(voiceblender_call_duration_seconds_count[5m])
Alert on audio-path panics being contained (each one drops a participant or a frame):
sum by (component, site) (rate(voiceblender_recovered_panics_total[5m])) > 0
Profiling (pprof)
Only available when the binary is built with the pprof build tag:
go build -tags pprof ./...
| Endpoint | Description |
|---|---|
GET /debug/pprof/ | Index of available profiles |
GET /debug/pprof/profile | 30-second CPU profile |
GET /debug/pprof/heap | Heap memory snapshot |
GET /debug/pprof/goroutine | All goroutine stack traces |
GET /debug/pprof/trace | Execution trace |
GET /debug/pprof/cmdline | Process command line |
Do not enable in production without access controls — these endpoints expose internal runtime state.
go tool pprof http://localhost:8080/debug/pprof/profile