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:

EventWhen
leg.connected, leg.early_media, leg.hold, leg.unhold, leg.disconnected, leg.transfer_*Successful completion
leg.command_failedThe 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"
  }
}
FieldTypeValues
idstringUUID
typestringsip_inbound, sip_outbound, webrtc, whatsapp_in, whatsapp_out, websocket_in, websocket_out, moq_in, livekit_publish, livekit_participant
statestringringing, early_media, connected, held, hung_up
room_idstringRoom ID if assigned, empty otherwise
mutedbooleantrue if the leg is muted (cannot be heard by others)
deafbooleantrue if the leg is deaf (cannot hear others)
heldbooleantrue if the call is on hold (SIP legs only)
rolestringRouting role used by the room's audio routing matrix (e.g. "customer", "agent", "supervisor"). Omitted/empty means full mesh.
sip_headersobjectDeprecated — X-* headers from the inbound INVITE. Only present on sip_inbound legs. Use headers for new code.
headersobjectCustom 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
  }
}
FieldTypeRequiredDescription
typestringyes"sip", "whatsapp" (see WhatsApp Business Calling below), "websocket" (see WebSocket Legs), or "livekit_room" (see LiveKit Room Legs)
tostringyesDestination. For sip legs, a SIP URI (e.g. "sip:alice@example.com"). For whatsapp legs, an E.164 phone number (with or without +).
uristringnoDeprecated alias for to (sip legs only). Kept for backward compat; prefer to.
fromstringnoCaller ID — sets the user part of the SIP From header (e.g. "+15551234567", "alice")
privacystringnoSIP Privacy header value (e.g. "id", "none")
ring_timeoutintegernoSeconds to wait for answer; 0 = no timeout
max_durationintegernoMaximum call duration in seconds after connect. The call is automatically hung up when reached. 0 or omitted = no limit.
codecsstring[]noCodec preference order. Supported: PCMU, PCMA, G722, opus, AMR-WB, AMR-NB. Defaults to engine config.
headersobjectnoCustom SIP headers to include in the outbound INVITE (e.g. X-Correlation-ID). Keys are header names, values are header values.
authobjectno for sip, yes for whatsappDigest 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_idstringnoRoom 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_urlstringnoPer-leg webhook URL. Events for this leg are routed exclusively to this URL instead of global webhooks.
webhook_secretstringnoHMAC-SHA256 signing secret for the per-leg webhook.
amdobjectnoEnable 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_detectionboolnoEmit speaking.started / speaking.stopped events for this leg. Omit to use the server default (SPEECH_DETECTION_ENABLED env var, default false).
rttboolnoOffer 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):

FieldTypeDefaultDescription
initial_silence_timeoutinteger2500Max milliseconds of silence before declaring no_speech.
greeting_durationinteger1500Speech duration threshold (ms). Continuous/cumulative speech exceeding this value classifies the answerer as machine.
after_greeting_silenceinteger800Silence duration (ms) after initial speech to declare human.
total_analysis_timeinteger5000Hard 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_lengthinteger100Minimum speech burst duration (ms) to count as a word. Shorter bursts are treated as noise.
beep_timeoutinteger0After detecting machine, continue listening up to this many ms for the voicemail beep tone (800–1200 Hz). 0 = beep detection disabled.

Constraintstotal_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 (default 300). 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=5061
  • SIP_TLS_CERT / SIP_TLS_KEY pointing 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"
}
FieldTypeRequiredDescription
typestringyes"whatsapp"
tostringyesDestination phone number (E.164, with or without +).
fromstringyesBusiness phone number, E.164 (with or without +). Used as the From URI user-part and, by default, as the digest auth username.
auth.passwordstringyesMeta-issued digest password for the business number.
auth.usernamestringnoOverride the digest auth username. Defaults to from with + stripped, per Meta's spec.
room_idstringnoRoom ID to auto-add the leg to once connected. Created on the fly if it doesn't exist.
app_idstringnoApplication 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 — missing to / from / password.
  • 503SIP_TLS_PORT not 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}/hold
  • DELETE /v1/legs/{id}/hold
  • POST /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 via POST /v1/legs with type: "websocket".
  • Inbound (websocket_in) — an external client connects to VoiceBlender. Created by upgrading GET /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
}
FieldTypeRequiredDescription
typestringyes"websocket"
urlstringyesws:// or wss:// target URL.
sample_rateintno8000/16000/24000/48000. Default 16000.
wire_formatstringnobinary (default) or json_base64.
sample_formatstringnos16le (only option in v1).
headersobjectnoHeaders sent on the upgrade request (e.g. Authorization, X-*, P-*).
room_idstringnoRoom to auto-add the leg to once connected.
rttbooleannoEnable bidi text channel. Default false.
ring_timeoutintnoSeconds to wait for the WS handshake to complete. Default unbounded.
app_id, webhook_url, webhook_secret, max_duration, accept_dtmf, speech_detectionnoSame 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, track audio — the room mix sent to the leg.
  • Uplink (client → server): server subscribes to namespace mic, track audio — 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 paramTypeRequiredDescription
sample_rateintno48000 only (encoder/decoder are 48 kHz). Default 48000.
room_idstringnoRoom to auto-add the leg to once connected. Created on demand if it does not exist.
app_idstringnoTag the leg for event filtering.
webhook_urlstringnoPer-leg webhook URL.
webhook_secretstringnoHMAC 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 connect as a path-item method, so this operation is documented in openapi.yaml under post with an x-actual-method: CONNECT extension. 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_publish represents what VoiceBlender publishes to LiveKit (mixed audio of all non-LK participants in the same VB room). Its AudioReader is empty (no upstream of its own); its AudioWriter is fed by the VB room mixer's mixed-minus-self output.
  • livekit_participant represents one remote LK participant's audio coming from LiveKit. Its AudioReader yields decoded PCM from the LK participant's audio track; its AudioWriter is 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=true
  • LIVEKIT_URL=wss://your.livekit.server (overridable per-request)
  • (optional) LIVEKIT_TOKEN_SIGNING_ENABLED=true + LIVEKIT_API_KEY + LIVEKIT_API_SECRET to 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:

FieldTypeRequiredDescription
urlstringnoLiveKit server endpoint (wss://...). Overrides LIVEKIT_URL.
tokenstringyes (mode 1)Pre-signed LiveKit JWT. When set, all other minting fields are ignored.
roomstringyes (mode 2)LiveKit room name. Required when minting.
identitystringyes (mode 2)LiveKit participant identity. Required when minting.
participant_namestringnoDisplay name surfaced in LK Room UIs.
permissionsobjectnoLK grant flags. See below. Defaults: publish=true, subscribe=true, data=false, admin=false.
token_ttlstringnoGo duration string (e.g. "30m", "6h"). Used only when minting. Defaults to LIVEKIT_DEFAULT_TOKEN_TTL (6h).
opus_bitrateintnoOverride LIVEKIT_OPUS_BITRATE for this leg. Must be 6000..510000.

permissions fields (each optional; nil → default):

FieldDefaultDescription
can_publishtruePublishing local audio.
can_subscribetrueSubscribing to remote tracks.
can_publish_datafalseData channel. Not used by the audio bridge.
room_adminfalseRequired 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 by JoinResponse
  • livekit_room — LK room name

Each livekit_participant leg's LegView.headers carries:

  • livekit_identity — the remote LK participant's identity
  • livekit_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_participant leg per audio track, registers it in LegMgr, emits leg.connected, and adds it to the publish leg's VB room (which fires leg.joined_room).
  • When a track is unpublished (or the LK participant disconnects), the matching participant leg is cleaned up; leg.disconnected fires with reason livekit_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.reason maps from the LiveKit DisconnectReason (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 — missing livekit block, missing token + signing disabled, missing room/identity in mint mode, invalid token_ttl, missing LiveKit URL.
  • 502 — LiveKit signaling failed (bad token, server unreachable, protocol error). No events emitted; no leg registered.
  • 503LIVEKIT_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. Set SIP_AUTO_RINGING=true to 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 leg
  • 404 — Leg not found
  • 409 — Leg is not in ringing state (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:

  1. You call /challenge on a ringing leg. VoiceBlender sends 401 with a freshly generated nonce and tears the current leg down (a leg.disconnected with reason="challenged" is published).
  2. The UAC retries the INVITE with an Authorization header (same Call-ID). VoiceBlender verifies the digest response against the credential you supplied.
  3. On success a new inbound leg is surfaced via leg.ringing with authenticated: true and auth_username set — answer/reject it as usual. On failure VoiceBlender replies 403 Forbidden and 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"]
}
FieldTypeDescription
realmstring (required)Digest realm advertised in the challenge.
usernamestring (optional)Expected username. When set, a verified response whose username differs is rejected. When omitted, any username that verifies is accepted.
passwordstringPlaintext secret. Provide this or ha1.
ha1stringPrecomputed MD5(username:realm:password) (hex), to avoid sending a plaintext secret. Provide this or password.
algorithmstring (optional)MD5 (default), SHA-256, or SHA-512-256.
qoparray (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 missing realm/credential
  • 404 — Leg not found
  • 409 — Leg is not in ringing or early_media state

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"
}
FieldTypeDescription
speech_detectionbool (optional)Override the server default for speaking.started / speaking.stopped events on this leg. Omit to use SPEECH_DETECTION_ENABLED (default false).
codecstring (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 offer
  • 404 — Leg not found
  • 409 — Leg is not in ringing or early_media state

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"
}
FieldTypeDescription
codecstring (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 offer
  • 404 — Leg not found
  • 409 — Leg is not in ringing state

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 found
  • 400 — Not a SIP leg
  • 409 — 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 found
  • 400 — Not a SIP leg
  • 409 — 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" }
FieldTypeDescription
reasonstring (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)

reasonSIP response
busy486 Busy Here
declined / rejected603 Decline
unavailable480 Temporarily Unavailable
not_found404 Not Found
forbidden403 Forbidden
server_error500 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 — Unknown reason value
  • 404 — 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 as leg.transfer_progress events. 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 in connected state. Its dialog identity is embedded as a Replaces parameter 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"
}
FieldTypeRequiredDescription
targetstringyesSIP URI of the third party
replaces_leg_idstringnoExisting 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 invalid target (including URIs without a host such as sip:)
  • 404 — Leg not found
  • 409 — Leg not connected, not a SIP leg, or replaces_leg_id is 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 via leg.transfer_requested but does nothing.
  • SIP_REFER_AUTO_DIAL=false (default) — the REFER is parked and surfaced as leg.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 within SIP_REFER_CONSULT_TIMEOUT_MS (default 2000 ms), the REFER auto-declines with 603 (fail-closed) and leg.transfer_failed fires.

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.

FieldTypeRequiredDescription
status_codeintyesSIP status to relay (100–699)
reasonstringnoReason 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.

FieldTypeRequiredDescription
successboolnotrue sends 200 OK; otherwise a failure NOTIFY is sent
status_codeintnoExplicit terminal status (defaults: 200 on success, 500 on failure)
reasonstringnoReason 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.

FieldTypeRequiredDescription
codeintnoSIP reject code (default 603)
reasonstringnoReason 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#" }
FieldTypeRequiredDescription
digitsstringyesDigits to send (0-9, *, #)

Response: 200 OK

{ "status": "sent" }

Errors:

  • 400 — Invalid JSON or empty digits
  • 404 — Leg not found
  • 500 — 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: false in the POST /v1/legs body.
  • When adding to a room: set accept_dtmf: false in the POST /v1/rooms/{id}/legs body.
  • At runtime: POST /v1/legs/{id}/dtmf/reject (disable) and POST /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=text section the caller offers — no configuration needed.
  • Outbound calls offer RTT only when the originate request sets "rtt": true (see POST /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" }
FieldTypeRequiredDescription
textstringyesUTF-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 text
  • 404 — Leg not found
  • 409 — RTT was not negotiated for this leg (peer didn't include m=text, or RTT_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"
}
FieldTypeRequiredDescription
urlstringone of url or toneURL of the audio file
tonestringone of url or toneBuilt-in telephone tone name (see below)
mime_typestringwith urlMIME type (audio/wav)
repeatintegernoRepeat count (0/1=once, -1=infinite)
volumeintegernoVolume 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 provided
  • 404 — Leg not found
  • 409 — 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:

FieldTypeRequiredDescription
volumeintegeryesVolume adjustment (-8 to 8, ~3dB per step, 0 = unchanged)

Response: 200 OK

{ "status": "ok" }

Errors:

  • 400 — Invalid JSON or volume out of range
  • 404 — 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
}
FieldTypeRequiredDescription
textstringyesText to synthesize
voicestringyesProvider-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).
providerstringnoTTS provider: "elevenlabs" (default), "aws", "google", "deepgram", or "azure"
model_idstringnoProvider-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).
languagestringnoLanguage 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.
promptstringnoStyle/tone instruction for promptable voice models (Google Gemini TTS only). E.g. "Read aloud in a warm, welcoming tone."
volumeintegernoVolume adjustment in dB (-8 to 8, default 0)
api_keystringnoElevenLabs: 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), set model_id and language explicitly; use prompt for style instructions.
  • deepgram — Deepgram TTS API. Requires an API key. The voice field 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 range
  • 404 — Leg not found
  • 409 — Leg has no audio writer
  • 503 — 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..."
}
FieldTypeRequiredDescription
storagestringno"file" (default) — local disk, "s3" — upload to S3 after recording stops
s3_bucketstringnoS3 bucket name. Overrides S3_BUCKET env var. Required if env var is not set.
s3_regionstringnoAWS region. Overrides S3_REGION env var. Default us-east-1.
s3_endpointstringnoCustom S3 endpoint (MinIO, etc.). Overrides S3_ENDPOINT env var.
s3_prefixstringnoKey prefix (e.g. recordings/). Overrides S3_PREFIX env var.
s3_access_keystringnoAWS access key ID. Overrides default credential chain.
s3_secret_keystringnoAWS 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 credentials
  • 404 — Leg not found
  • 409 — Leg has no audio reader
  • 500 — 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"
}
FieldTypeRequiredDescription
languagestringnoLanguage code (e.g. "en", "es")
partialbooleannoEmit partial (non-final) transcripts
providerstringnoSTT provider: "elevenlabs" (default), "deepgram", or "azure"
api_keystringnoAPI 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 found
  • 409 — Leg not connected, STT already running, or leg has no audio reader
  • 503 — 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-..."
}
FieldTypeRequiredDescription
agent_idstringyesElevenLabs agent ID
first_messagestringnoOverride the agent's first message
languagestringnoLanguage code (e.g. "en", "es")
dynamic_variablesobjectnoKey-value pairs passed to the agent as dynamic variables
api_keystringnoAPI 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-..."
}
FieldTypeRequiredDescription
assistant_idstringyesVAPI assistant ID
first_messagestringnoOverride the agent's first message
variable_valuesobjectnoKey-value pairs passed as VAPI variable values
api_keystringnoAPI 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"
}
FieldTypeRequiredDescription
websocket_urlstringyesWebSocket 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-..."
}
FieldTypeRequiredDescription
settingsobjectnoFull Deepgram agent settings object. When omitted, sensible defaults are used (nova-3 STT, gpt-4o-mini LLM, aura-2-asteria-en TTS).
greetingstringnoAgent greeting message
languagestringnoLanguage code (e.g. "en", "es")
api_keystringnoAPI 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 InjectAgentMessage via WebSocket
  • Pipecat — sends a protobuf TextFrame via WebSocket
  • VAPI — sends add-message via HTTP control URL
  • ElevenLabs — not supported (returns 501)

Request:

{
  "message": "The customer's name is John and their order number is 12345."
}
FieldTypeRequiredDescription
messagestringyesContext 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 }
FieldTypeRequiredDescription
idstringnoCustom room ID. Auto-generated UUID if omitted.
sample_rateintegernoMixer 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_urlstringnoPer-room webhook URL. Events for this room are routed exclusively to this URL instead of global webhooks.
webhook_secretstringnoHMAC-SHA256 signing secret for the per-room webhook.

Response: 201 Created — Room object (empty participants)

Errors:

  • 400 — Invalid sample rate
  • 409 — 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
}
FieldTypeRequiredDescription
leg_idstringyesID of the leg to add
muteboolnoApply 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).
deafboolnoApply this deaf state to the leg atomically before it joins the mixer. Omit to leave current state untouched.
rolestringnoApply 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}):

directionPath room sendsPath room receives
bidirectional (default)yesyes
sendyesno
receivenoyes
nonenono

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" }
FieldDescription
idOptional custom bridge ID (auto-generated UUID if omitted)
room_idThe other room to join (required)
directionbidirectional (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 S iff R_S is in matrix[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"
}
FieldTypeRequiredDescription
urlstringone of url or toneURL of the audio file
tonestringone of url or toneBuilt-in telephone tone name
mime_typestringwith urlMIME type (audio/wav)
repeatintegernoRepeat count (0/1=once, -1=infinite)
volumeintegernoVolume 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 provided
  • 404 — Room not found
  • 409 — 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:

FieldTypeRequiredDescription
volumeintegeryesVolume adjustment (-8 to 8, ~3dB per step, 0 = unchanged)

Response: 200 OK

{ "status": "ok" }

Errors:

  • 400 — Invalid JSON or volume out of range
  • 404 — 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
}
FieldTypeRequiredDescription
textstringyesText to synthesize
voicestringyesProvider-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).
providerstringnoTTS provider: "elevenlabs" (default), "aws", "google", "deepgram", or "azure"
model_idstringnoProvider-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.
languagestringnoLanguage code (e.g. "en-US", "pl-pl"). Required for Google Gemini TTS voices that use short names. Auto-extracted from full voice names.
promptstringnoStyle/tone instruction for promptable voice models (Google Gemini TTS only).
volumeintegernoVolume adjustment in dB (-8 to 8, default 0)
api_keystringnoElevenLabs: 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 range
  • 404 — Room not found
  • 409 — Room has no participants
  • 503 — 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..."
}
FieldTypeRequiredDescription
storagestringno"file" (default) — local disk, "s3" — upload to S3 after recording stops
multi_channelbooleannoWhen 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_bucketstringnoS3 bucket name. Overrides S3_BUCKET env var. Required if env var is not set.
s3_regionstringnoAWS region. Overrides S3_REGION env var. Default us-east-1.
s3_endpointstringnoCustom S3 endpoint (MinIO, etc.). Overrides S3_ENDPOINT env var.
s3_prefixstringnoKey prefix (e.g. recordings/). Overrides S3_PREFIX env var.
s3_access_keystringnoAWS access key ID. Overrides default credential chain.
s3_secret_keystringnoAWS 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 credentials
  • 404 — Room not found
  • 409 — Room has no participants
  • 500 — 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 }
  }
}
FieldTypeDescription
filestringPath/URI of the full mix recording (mono)
multi_channel_filestringPath/URI of the multi-channel WAV file. Only present when multi_channel: true was used.
channelsobjectMap of leg ID to channel metadata. Only present when multi_channel: true was used.
channels[].channelintegerZero-based channel index in the multi-channel WAV
channels[].start_msintegerMilliseconds from recording start when this participant joined
channels[].end_msintegerMilliseconds 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"
}
FieldTypeRequiredDescription
languagestringnoLanguage code
partialbooleannoEmit partial (non-final) transcripts
providerstringnoSTT provider: "elevenlabs" (default), "deepgram", or "azure"
api_keystringnoAPI 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 found
  • 409 — STT already running on this room, or room has no participants
  • 503 — 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-..."
}
FieldTypeRequiredDescription
agent_idstringyesElevenLabs agent ID
first_messagestringnoOverride the agent's first message
languagestringnoLanguage code (e.g. "en", "es")
dynamic_variablesobjectnoKey-value pairs passed to the agent as dynamic variables
api_keystringnoAPI 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-..."
}
FieldTypeRequiredDescription
assistant_idstringyesVAPI assistant ID
first_messagestringnoOverride the agent's first message
variable_valuesobjectnoKey-value pairs passed as VAPI variable values
api_keystringnoAPI 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"
}
FieldTypeRequiredDescription
websocket_urlstringyesWebSocket 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-..."
}
FieldTypeRequiredDescription
settingsobjectnoFull Deepgram agent settings object. When omitted, sensible defaults are used.
greetingstringnoAgent greeting message
languagestringnoLanguage code (e.g. "en", "es")
api_keystringnoAPI 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 InjectAgentMessage via WebSocket
  • Pipecat — sends a protobuf TextFrame via WebSocket
  • VAPI — sends add-message via HTTP control URL
  • ElevenLabs — not supported (returns 501)

Request:

{
  "message": "The customer's name is John and their order number is 12345."
}
FieldTypeRequiredDescription
messagestringyesContext 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:

ParamTypeDescription
app_idstring (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

CommandPayloadDescription
list_legs(none)List all legs
get_leg{"id":"..."}Get a single leg
create_legCreateLegRequestOriginate 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_roomCreateRoomRequestCreate 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.

CommandPayloadDescription
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"
}
FieldTypeRequiredDescription
sdpstringyesSDP offer from the browser.
app_idstringnoTag 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 offer
  • 500 — 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 a total_analysis_time too short to reach any verdict) or leg is not a SIP leg
  • 404 — Leg not found
  • 409 — Leg is not in connected state (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
}
FieldTypeRequiredDescription
candidatestringyesICE candidate string
sdpMidstringnoMedia stream ID
sdpMLineIndexintegernoMedia description index

Response: 200 OK

{ "status": "added" }

Errors:

  • 400 — Invalid JSON or leg is not a WebRTC leg
  • 404 — Leg not found
  • 500 — 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
}
FieldTypeDescription
candidatesarrayICE candidates gathered since last poll
donebooleantrue when ICE gathering is complete

Errors:

  • 400 — Leg is not a WebRTC leg
  • 404 — 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:

CommandPayloadResult
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:

  1. Global webhook — set via WEBHOOK_URL and WEBHOOK_SECRET environment variables. Receives all events that don't have a more specific webhook.
  2. Per-leg webhook — set via webhook_url / webhook_secret in the create leg request body, or via X-Webhook-URL / X-Webhook-Secret SIP headers on inbound calls.
  3. Per-room webhook — set via webhook_url / webhook_secret in the create room request body.

Routing Priority

When an event is emitted, webhooks are resolved in this order (highest to lowest):

  1. Leg's webhook — used when the event carries a leg_id and that leg has a webhook_url set.
  2. Room's webhook — used when the event has a room_id (but no matching leg webhook) and that room has a webhook_url set.
  3. Global webhook — used for all other events (configured via WEBHOOK_URL env 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).

EventDescriptionData Fields
leg.ringingSIP or WhatsApp call ringingleg_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_mediaOutbound leg received 183 Session Progress with SDP; media pipeline activeleg_id, leg_type
leg.connectedLeg answered/connectedleg_id, leg_type
leg.disconnectedLeg hung upleg_id, cdr, quality (see CDR-style structure below)
leg.joined_roomLeg added to roomleg_id, room_id
leg.left_roomLeg removed from roomleg_id, room_id
leg.mutedLeg mutedleg_id
leg.unmutedLeg unmutedleg_id
leg.deafLeg deafenedleg_id
leg.undeafLeg undeafenedleg_id
leg.holdLeg put on hold (local or remote)leg_id, leg_type
leg.unholdLeg taken off hold (local or remote)leg_id, leg_type
leg.command_failedAn asynchronous leg command failed after the HTTP 202 was returnedleg_id, command (e.g. ring, early_media, hold, unhold, add_to_room), error
leg.transfer_initiatedWe sent a SIP REFER for this legleg_id, kind (blind/attended), target, replaces_leg_id
leg.transfer_requestedA 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_progressNOTIFY sipfrag for an in-flight transferleg_id, status_code, reason
leg.transfer_completedTransfer reached terminal 2xx; leg is hung upleg_id, status_code, reason
leg.transfer_failedTransfer ended in non-2xx or local errorleg_id, status_code, reason, error
dtmf.receivedDTMF digit receivedleg_id, digit, seq
rtt.receivedRTT (T.140 / RFC 4103) text chunk receivedleg_id, text, seq, loss_marker
speaking.startedParticipant started speakingleg_id, room_id (if in a room)
speaking.stoppedParticipant stopped speakingleg_id, room_id (if in a room)

Note: speaking.started and speaking.stopped events fire for any connected leg, whether standalone or in a room. When the leg is in a room, the event includes room_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": true on POST /v1/legs (outbound) or POST /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.finished and tts.finished carry reason and played_ms so you can tell whether a prompt was heard in full or was cut short, and by how much.

reason is completed when the audio played through to its end, and stopped when 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 from reason alone. To distinguish a hangup from a deliberate stop, look for a co-emitted leg.disconnected event. Tone playback never ends on its own, so it always reports stopped.

played_ms is how much audio was actually written to the leg or room, in milliseconds. It counts audio played, not the source file's duration: a repeated playback accumulates across every iteration, so played_ms can 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.disconnected for a livekit_participant leg (Model B). speaking.started / speaking.stopped apply per-leg as usual. The leg.disconnected.reason for an LK participant leg is livekit_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

FieldTypeDescription
reasonstringSee Disconnect Reasons below
duration_totalfloatSeconds from leg creation (INVITE sent/received) to disconnect
duration_answeredfloatSeconds from answer (200 OK) to disconnect. 0 if the leg was never answered.

quality Object (omitted when no media was received)

FieldTypeDescription
mos_scorefloatMean Opinion Score (1.0–5.0) estimated via simplified E-model (ITU-T G.107) from packet loss and jitter
rtp_packets_receivedintegerTotal inbound RTP audio packets received
rtp_packets_lostintegerEstimated lost packets based on sequence number gaps
rtp_jitter_msfloatInter-arrival jitter in milliseconds (RFC 3550 §A.8)

Disconnect Reasons:

ReasonDescription
api_hangupHung up via DELETE /v1/legs/{id}
remote_byeRemote party sent BYE
caller_cancelInbound caller hung up before answer
ring_timeoutOutbound ring_timeout expired before answer
max_durationOutbound max_duration reached after connect
busyRemote returned 486 Busy Here
unavailableRemote returned 480 Temporarily Unavailable
not_foundRemote returned 404 Not Found
forbiddenRemote returned 403 Forbidden
unauthorizedRemote returned 401/407 Authentication Required
timeoutRemote returned 408 Request Timeout
cancelledINVITE was cancelled (487 Request Terminated)
not_acceptableRemote returned 488 Not Acceptable Here
service_unavailableRemote returned 503 Service Unavailable
declinedRemote returned 603 Decline
sip_{code}Other SIP failure response (e.g. sip_500)
rtp_timeoutNo RTP packets received for 30 seconds
session_expiredSIP session timer expired without refresh (RFC 4028)
invite_failedINVITE failed for a non-SIP reason (transport error, DNS failure, etc.)
connect_failedCall answered but media/codec negotiation failed
ice_failureWebRTC ICE connection failed
room_deletedLeg was in a room that was deleted via DELETE /v1/rooms/{id}
transfer_completedLeg ended because a transfer it initiated reached terminal 2xx
rejectedInbound leg rejected by API via DELETE /v1/legs/{id} with reason (also see other reason values from the rejection mapping table)
mixer_panicThe 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 VariableDefaultDescription
INSTANCE_ID(auto-generated UUID)Instance identifier, included in all API response bodies and webhook events
HTTP_ADDR:8080REST 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_HEADERSfalseWhen 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_IP127.0.0.1IP 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_PORT5060SIP listen port
SIP_HOSTvoiceblenderSIP User-Agent name
ICE_SERVERSstun:stun.l.google.com:19302STUN/TURN URLs (comma-separated)
RECORDING_DIR/tmp/recordingsRecording output directory
LOG_LEVELinfoLog 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_REGIONus-east-1AWS 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_ENABLEDfalseEnable disk-backed TTS audio cache. Cached audio is stored on disk and persists across restarts.
TTS_CACHE_DIR/tmp/tts_cacheDirectory for cached TTS audio files.
TTS_CACHE_INCLUDE_API_KEYfalseInclude 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=uac and refresher=uas roles
  • 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

StepWhat happensEvent emitted
REGISTER with non-zero expiresBinding added/refreshed; 200 OK is returned with the granted expirysip.registration_active
REGISTER with Contact: * and Expires: 0Every contact under the AOR is removedsip.registration_expired (reason: unregistered)
REGISTER with expires=0 on a specific ContactThat single contact is removedsip.registration_expired (reason: unregistered)
TTL elapsesSweeper removes the bindingsip.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=falsePrior Contacts under the AOR are displacedsip.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:

  1. An inbound REGISTER (with a Contact) arrives. VoiceBlender publishes a sip.registration_attempt event (carrying attempt_id, aor, source_address, transport, etc.) and parks the REGISTER awaiting a decision for up to SIP_INBOUND_AUTH_CONSULT_TIMEOUT_MS (default 2000 ms).
  2. The client responds by attempt_id:
    • challenge — VoiceBlender replies 401 with a WWW-Authenticate digest challenge.
    • accept — bind and reply 200 OK.
    • reject — reply 403 (or a custom code/reason).
  3. If challenged, the UA retries the REGISTER with an Authorization header (same Call-ID). VoiceBlender verifies the digest against the supplied credential: on success it binds and replies 200 OK; on failure it replies 403 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

VariableDefaultDescription
SIP_REGISTRATION_DEFAULT_EXPIRES_SECONDS3600Used when the REGISTER carries no Expires value
SIP_REGISTRATION_MAX_EXPIRES_SECONDS7200Upper clamp on the granted expiry
SIP_REGISTRATION_SWEEP_INTERVAL_MS1000How often the expiry sweeper runs
SIP_REGISTRATION_ALLOW_MULTIPLE_CONTACTStrueWhen false, every REGISTER replaces any prior Contacts for the AOR
SIP_INBOUND_AUTH_CONSULT_TIMEOUT_MS2000How long an inbound REGISTER is parked awaiting a challenge/accept/reject decision before the fallback applies
SIP_INBOUND_REGISTER_DEFAULTrejectFallback for an undecided inbound REGISTER: reject (403, fail-closed default) or accept (bind, legacy fail-open)
SIP_INBOUND_AUTH_NONCE_TTL_SECONDS60Lifetime 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

ActionTriggerEvent published
Create trunk → first REGISTER succeedsPOST /v1/sip/trunkssip.outbound_registration_active
Periodic refresh succeedstimer fires at granted_expires * SIP_OUTBOUND_REGISTRATION_REFRESH_RATIOsip.outbound_registration_active (re-emitted for liveness)
Transport error or non-2xx responseREGISTER attempt fails (after digest retry)sip.outbound_registration_failed
Upstream binding lapsed while still failinggranted lifetime expires and refresh has not recoveredsip.outbound_registration_expired (reason: refresh_failed) — emitted once per outage; resets on the next successful REGISTER
DELETE /v1/sip/trunks/{id}operator removes the trunksip.outbound_registration_expired (reason: unregistered)
Server shutdownevery trunk is unregistered in parallelsip.outbound_registration_expired (reason: unregistered or shutdown)

Implicit call wiring

  • Outbound: POST /v1/legs with from equal to a registered trunk's AOR (full URI like sip:alice@pbx.example) or just the user-part (alice) auto-attaches the trunk's digest credentials and adds a loose-route Route: <trunk's registrar URI;lr> header. Caller-supplied auth always wins. The resulting leg's leg.ringing event carries trunk_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_id on the leg.ringing event. 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):

FieldRequiredDescription
registrar_uriyesUpstream registrar SIP URI. sips: / ;transport=tls switches to TLS.
aoryesAddress-of-record. Becomes the From URI and the implicit-match key for outbound calls.
usernamenoDigest username. Defaults to the AOR user-part.
passwordyesDigest password. Never returned in any response.
contact_usernoOverride the Contact user-part. Defaults to the AOR user-part.
expires_secondsnoRequested 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

EventWhen
sip.outbound_registration_activeREGISTER (initial or refresh) returned 2xx. Carries trunk_id, aor, registrar, contact, granted_expires_seconds, expires_at, call_id.
sip.outbound_registration_failedREGISTER 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_expiredTrunk 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

VariableDefaultDescription
SIP_OUTBOUND_REGISTRATION_DEFAULT_EXPIRES_SECONDS3600Default requested expiry when expires_seconds is omitted on create
SIP_OUTBOUND_REGISTRATION_MIN_EXPIRES_SECONDS60Lower clamp on requested expiry
SIP_OUTBOUND_REGISTRATION_MAX_EXPIRES_SECONDS7200Upper clamp on requested expiry
SIP_OUTBOUND_REGISTRATION_REFRESH_RATIO0.5Fraction of granted expiry at which the trunk refreshes
SIP_OUTBOUND_REGISTRATION_FAILURE_BACKOFF_MAX_MS300000Upper 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

MetricTypeLabelsDescription
voiceblender_active_legsGaugeNumber of legs currently in any state (ringing, early_media, connected, held)
voiceblender_active_roomsGaugeNumber of rooms currently open
voiceblender_legs_totalCountertype, stateTotal leg lifecycle transitions. type: sip_inbound, sip_outbound. state: ringing, connected, disconnected
voiceblender_disconnect_reasons_totalCountertype, reasonTotal disconnected legs by type and reason (e.g. remote_bye, api_hangup, rtp_timeout)
voiceblender_call_duration_secondsHistogramtypeAnswered call duration (time from answer to hangup). Use rate(sum)/rate(count) for ACD
voiceblender_call_total_duration_secondsHistogramtypeTotal leg lifetime including ringing time (time from leg creation to hangup)
voiceblender_recovered_panics_totalCountercomponent, sitePanics recovered and contained instead of crashing the process. component: mixer, room. site: readLoop, writeLoop, mixTick, panicTeardown, deleteHangup
Go runtime metricsStandard 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 ./...
EndpointDescription
GET /debug/pprof/Index of available profiles
GET /debug/pprof/profile30-second CPU profile
GET /debug/pprof/heapHeap memory snapshot
GET /debug/pprof/goroutineAll goroutine stack traces
GET /debug/pprof/traceExecution trace
GET /debug/pprof/cmdlineProcess 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