VoxML — call-control language
VoxML is voxio's JSON instruction language for controlling live calls. A flow is an ordered list of verbs — speak, play audio, collect digits, record, bridge a second leg — executed sequentially against an answered call. If you know TwiML, you already know VoxML: verbs and attributes map one-to-one, expressed as JSON.
Overview
A VoxML document is a JSON array of verb objects. Each
object carries a "verb" discriminator plus the verb's
attributes as camelCase fields. voxio executes the verbs strictly in
order; when the end of a document is reached without a transfer of
control, the call is hung up (TwiML semantics).
[
{ "verb": "answer" },
{ "verb": "say", "text": "Welcome to voxio." },
{ "verb": "pause", "length": 1 },
{ "verb": "play", "url": "https://cdn.example.com/jingle.mp3" },
{ "verb": "hangup" }
]
Unknown verbs and unknown fields are rejected at parse time — a typo fails fast with a clear error instead of silently doing nothing.
Documents
A flow execution is a chain of one or more documents:
- The initial document starts the flow (stored with an inbound number, posted inline as
playbook, or fetched fromplaybook_urlon an outbound call). - Control transfers — a
redirect, or theactionUrlof agather/record/dial— fetch a new document over HTTPS that replaces the current one. Local branches (routines-form documents, below) transfer control without any network involved. - The chain ends with a
hangupverb, the end of a document/routine, the caller hanging up, the fetch limit (default 10 documents per call), or the branch limit (50 routine jumps per document).
Routines-form documents
Besides the flat verb array, a document can be an object with named
routines and an optional session block —
a complete call plan, every outcome included, in one self-contained
payload. gather and dial jump between routines
via branches; no mid-call fetch means no application-server
dependency while a call is live.
{
"session": {
"id": "sess_8f3a2b91", // your correlation key — echoed in every webhook
"callbackUrl": "https://app.example.com/voxio", // receives session.* and playbook.branch events
"callbackToken": "whsec_…" // HMAC signing secret for those events
},
"routines": {
"main": [ /* entry routine — required */ ],
"accepted": [ /* … */ ],
"goodbye": [ /* … */ ]
}
}
- Execution starts at
main; reaching a routine's end without a transfer hangs up (TwiML semantics). gatherbranch keys: the collected digits (exact match),timeout(no input),default(any other digits).dialbranch keys:completed,busy,no-answer,failed,screen-timeout,default.- Branches win over
actionUrl; an unmatched outcome falls back toactionUrl(when set) or falls through. - Every branch taken emits a fire-and-forget
playbook.branchwebhook (to the session callback when declared) — full path visibility without being in the control loop. - Branch targets are validated at parse time; unknown routines are rejected before the call runs.
- The
session.idis attached to all webhooks for the call (payloads +X-Voxio-Sessionheader) and to voxio's logs — grep by your ID, not voxio's SIDs. - A
limitverb inheritscallbackUrl/callbackTokenfrom the session block when it doesn't carry its own. - A call has at most one session, armed by the first document that carries a
sessionblock. When the first document has none (e.g. an inbound number's storedredirect), the first fetched document that carries one arms it; a later document never re-arms or replaces an armed session.
PATCH. Add
actionUrls only where a decision genuinely needs live data.
TwiML compatibility
Anywhere a document is accepted — stored on a number, posted inline, or
returned by your server from a fetch (any Content-Type) — a
TwiML XML document works too. Voxio detects the leading
< and translates the verbs to their JSON equivalents
before ordinary validation. Existing Twilio voice endpoints can usually be
pointed at voxio unchanged.
- Supported verbs:
Say(voice, loop),Play(loop),Pause,Hangup,Redirect,Gather(numDigits, finishOnKey, timeout, action, nested Say/Play/Pause prompt; DTMF only),Record(action, maxLength, finishOnKey, playBeep),Dial(callerId, timeout, action; plain text or one bare<Number>), andReject(approximated ashangup— voxio has no pre-answer reject). - Twilio defaults are materialized: a
GatherwithoutfinishOnKeygets"#"; aRecordwithoutplayBeepbeeps — migrated documents behave as they did on Twilio. - Ignored attributes (no-ops under voxio's execution
model):
methodeverywhere (voxio always POSTs),language,bargeIn(prompts are always interruptible),actionOnEmptyResult,trim,hangupOnStar,Record timeout. - Hard errors — anything that would silently change
call behavior: TwiML verbs with no translation
(
<Enqueue>,<Connect>,<Stream>,<Sms>, … — the JSON verbsenqueue,connect, andstreamare native VoxML; switch the document to JSON to use them),Dialnouns other than a bare<Number>,Gather input="speech",Play digits,Record transcribe, SSML markup insideSay,loop="0"(infinite), unknown attributes (typo safety), and relativeactionURLs (voxio requires absolute URLs). - Routines,
branches, thesessionblock, background audio, and dial screening have no TwiML syntax — switch a document to JSON to use them. Translation is per-document: a TwiML document mayRedirectto a JSON one and vice versa.
Where flows run
Inbound calls — stored flow
Each phone number (DID) stores its flow document directly. Configure
it in the dashboard under Trunks → Edit → Inbound calls, or
via the numbers API (playbook_json field). When a call
arrives for the number, voxio answers and executes the stored
document — no HTTP fetch, no application server required.
Outbound calls — inline flow or flow URL
POST /v1/accounts/{as}/calls with either
playbook (the complete document inline — validated up front,
never fetched) or playbook_url (fetched when the callee
answers). playbook, playbook_url, and
room_sid are mutually exclusive. Add
timeout (seconds, 0..600) to bound how long the callee may
ring before voxio gives up — see
answer deadline. Nothing is fetched and
no playbook starts when a call is never answered.
timeoutonPOST /calls— seconds the outbound call you are creating may ring. Still unanswered when it elapses, voxio cancels the attempt: the call ends with hangup causeno_answerandduration_seconds0. Omitted or0means no deadline of voxio's own — the carrier's timer decides, which is typically 1–3 minutes and not under your control. Ringing only: once the callee answers the deadline is gone.
The call then ends with statusno-answer; the webhook iscall.ended(every call ends with exactly one, answered or not). Readstatusandhangup_causeon its payload — the full Call — to tell outcomes apart.timeouton thedialverb — the same thing for the second leg a flow bridges in (default 30 s). Reaching it yields dial statusno-answer, which your branches andactionUrlcan act on.maxMinutesin thelimitblock — the budget for an answered session, with warnings,onExpiry, and mid-call extension viaPATCH /v1/accounts/{as}/calls/{call_sid}/limit. This is the one to reach for when you want a call to stop after N minutes of conversation.
Fetch protocol
Whenever voxio fetches a document (outbound initial document,
redirect, or an actionUrl), it issues an HTTP
POST to your URL with the current call state as JSON:
POST {your-url}
Content-Type: application/json
User-Agent: voxio-flow/1.0
X-Voxio-Signature: t=1718100000,v1=… // HMAC-SHA256, same scheme as webhooks
{
"call_sid": "CA…",
"account_sid": "AC…",
"room_sid": "RM…",
"from": "+4915112345678",
"to": "+4930123456",
"direction": "inbound",
"call_status": "in-progress",
// present only on the matching action callback:
"digits": "42#", // gather
"recording_sid": "RE…", // record
"dial_call_sid": "CA…", // dial
"dial_call_status": "completed" // dial: completed | busy | no-answer | failed
}
Your endpoint must respond 200 OK with the next VoxML document as the body.
- Requests are signed with your account's webhook signing secret when one is configured (
X-Voxio-Signature). - One automatic retry on network errors and 5xx responses.
- Documents are capped at 64 KiB; fetches must resolve to public addresses (no private/loopback ranges).
- Fetch latency is live silence for the caller — keep handlers fast and prefer static documents where possible.
Action callbacks
Interactive verbs hand their result to your application through their
actionUrl: voxio POSTs the call state (with the verb's
result fields, see above) and executes the document you return. The rest
of the current document is discarded — exactly like TwiML's
action.
| Verb | Callback fields | actionUrl omitted |
|---|---|---|
gather | digits | Stored flows: digits are dropped, execution continues with the next verb. URL-fetched flows: digits are re-POSTed to the current document URL. |
record | recording_sid | Execution continues with the next verb. |
dial | dial_call_sid, dial_call_status | Execution continues with the next verb. |
WebRTC legs
A browser can be a first-class call participant. There are two ways in, for two different jobs:
- Inside a session — declare a participant with
"to": {"type": "browser"}in a session playbook. Your backend gets a join token fromPOST /sessionsand hands it to the browser; the browser talks to a small public, token-authed surface. No account credentials ever reach the client. This is the recommended path for anything session-shaped (advice calls, conferences, agent consoles). - Outside sessions — the channels API:
account-authenticated building blocks (
/channels,/rooms,/calls) your backend composes into its own topology. The browser's SDP is proxied through your server, which holds the credentials.
Inside a session: join tokens
Declare the participant as a call object with
"to": {"type": "browser"}. A browser leg is not dialed — an
address on the endpoint, from and
screen are parse errors on it
(the accept gate lives in your UI, before the browser sends its
offer). timeout is the connect window;
reconnectWindow (0–300 s, default 45) is how long a
disconnected browser may return before the leg ends.
{
"session": { "id": "platform-key-123", "host": "advisor", "participants": ["customer"],
"callbackUrl": "https://app.example.com/voxio", "callbackToken": "whsec_…" },
"objects": {
"advisor": { "type": "call", "to": { "type": "browser" }, "timeout": 60, "reconnectWindow": 45 },
"customer": { "type": "call", "to": "+4915112345678" } // phone shorthand
}
}
Submit via POST /v1/accounts/{account_sid}/sessions. Every
WebRTC participant gets a webrtc block in the response —
forward it to your frontend verbatim:
{
"sid": "SS…",
"status": "in-progress",
"legs": [ … ], // the Session resource, as GET /sessions/{sid}
"participants": {
"customer": { "endpoint": { "type": "phone", "address": "+4915112345678", "transport": "sip" }, "state": "queued" },
"advisor": {
"endpoint": { "type": "browser", "address": "advisor", "transport": "webrtc" }, "state": "awaiting_connection",
"webrtc": {
"join_url": "https://voxio.example/v1/webrtc/join",
"join_token": "vjt_…", // opaque: routes AND authorizes the offer
"expires_at": "…", // session teardown, not a connect deadline
"ice_servers": [ { "urls": ["turn:…"], "username": "…", "credential": "…" } ]
}
}
}
}
The browser needs only the token. The public surface is CORS-open and token-authed — three endpoints:
| Endpoint | Body | Returns |
|---|---|---|
POST /v1/webrtc/join/info | {token} | ICE servers + expires_at — for a browser that has only an invite link. |
POST /v1/webrtc/join | {token, sdp_offer} | {sdp_answer}. Re-POSTing under the same token while the leg lives is a reconnect. |
POST /v1/webrtc/join/ice | {token, candidate, sdpMid, sdpMLineIndex} | — (trickle ICE) |
POST /v1/webrtc/join/digit | {token, digit} | — (DTMF equivalent: limit-extend digits, gathers) |
Minimal browser client (see /join for the full reference implementation with reconnect backoff):
const info = await post("/v1/webrtc/join/info", { token }); // ICE bootstrap
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
const pc = new RTCPeerConnection({ iceServers: info.ice_servers });
mic.getTracks().forEach(t => pc.addTrack(t, mic));
pc.ontrack = e => { audioEl.srcObject = e.streams[0]; }; // remote audio
pc.onicecandidate = e => { if (e.candidate) post("/v1/webrtc/join/ice", {
token, candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid || "", sdpMLineIndex: e.candidate.sdpMLineIndex,
}); }; // (queue until answered — see /join)
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const { sdp_answer } = await post("/v1/webrtc/join", { token, sdp_offer: offer.sdp });
await pc.setRemoteDescription({ type: "answer", sdp: sdp_answer });
pc.oniceconnectionstatechange = () => { // reconnect gesture:
if (["disconnected", "failed"].includes(pc.iceConnectionState)) // new pc + re-POST the
reconnectWithFreshPeerConnection(); // offer, same token
};
- Invite links:
https://…/join#vjt_…— the bundled invite page drives this exact flow for anyone you send the link to. The token rides the URL fragment, so it never appears in server logs. - Token lifecycle: session-scoped — valid from
creation until the leg ends, one active connection at a time. The leg's
timeoutseparately answers "did they connect in time?" (no-answerwhen not). Re-mint a lost/leaked token withPOST /sessions/{session_sid}/participants/{name}/webrtc(backend-authenticated; invalidates the old one). - Early join: a browser connecting before its leg's
turn (per
connect_priority) parks on a solo mixer and is bridged when its turn comes — waiting-room semantics for free. - Reconnect: on ICE disconnect the leg leaves the room
and parks for
reconnectWindowseconds; billing occupancy pauses the session clock meanwhile. Only when the window lapses doesendedfire. - Lifecycle events:
participant.answered(ICE/DTLS up),participant.reconnecting/reconnected,participant.failed(never connected),participant.ended— signed to the session'scallbackUrl. Each payload names theparticipant, itsleg_sidand theendpointit reaches (e.g.{"type": "browser", "address": "advisor", "transport": "webrtc"}; omitted for hunts and templated addresses). - Try it live: the session tester builds a WebRTC session against your account and hands out invite links.
Outside sessions: the channels API
When you want your own topology instead of a session playbook, compose
the account-authenticated primitives. A channel is a
bare WebRTC leg; a room mixes legs; a
call with room_sid bridges PSTN in. The
browser never holds credentials — your backend relays the SDP:
// 1. backend: create a room POST /v1/accounts/{sid}/rooms → { "sid": "RM…" } // 2. browser → backend → voxio: channel from the browser's SDP offer POST /v1/accounts/{sid}/channels { "sdp_offer": "v=0…" } → { "channel_sid": "CH…", "sdp_answer": "v=0…", "ice_servers": [ … ] } // 3. trickle ICE (relay the browser's candidates) POST /v1/accounts/{sid}/channels/CH…/ice { "candidate": "candidate:…", "sdpMid": "0", "sdpMLineIndex": 0 } // 4. mix the channel into the room POST /v1/accounts/{sid}/rooms/RM…/participants { "channel_sid": "CH…" } // 5. bridge a phone in — on answer the PSTN leg joins the same mixer POST /v1/accounts/{sid}/calls { "to": "+4915112345678", "room_sid": "RM…" } // teardown: DELETE the call, channel, and room (any order)
- Offer first, ICE after: the channel is created
from the browser's offer, so TURN credentials arrive with the
answer. Apply them best-effort
(
pc.setConfiguration({iceServers})) — voxio's side is publicly reachable, so host/server-reflexive candidates connect in most networks and TURN is the fallback, not the norm. - No room required: a bare channel is already
addressable —
POST /channels/{sid}/playbacksplays a media-library file onto it,POST /channels/{sid}/ttsspeaks to it. Useful for prompts before any bridging. - Sessions vs channels: the channels path gives you no
join tokens, no reconnect window, no session billing/limits, and no
participant.*events — you own the lifecycle. Prefer sessions unless you specifically need the raw building blocks. - TURN TTL invariant: ICE credentials must outlive the
longest call (
TURN_CREDENTIAL_TTL, default 24 h — an expired credential kills the relay mid-call, not at connect).
Verb reference
answer
no-op on outbound
Answers an inbound call (sends the 200 OK). On outbound calls the leg
is already answered, so answer is a no-op — keeping
documents portable across call directions. No fields.
{ "verb": "answer" }
say
blocks until finishedSpeaks text to the caller via TTS (ElevenLabs). Synthesized audio is cached server-side, so repeated phrases replay instantly without re-synthesis.
| Field | Type | Default | Description |
|---|---|---|---|
| text | string | required | The text to speak. Max 4096 bytes. |
| voice | string | server default | A voxio voice name from the account's voice registry (resolves to its TTS provider/model). Omit to use the session or system default. |
| loop | int | 1 | Repetitions, 1–10. |
{ "verb": "say", "text": "Your order has shipped.", "loop": 2 }
say needs
ELEVENLABS_API_KEY configured on the server; without it
the verb fails the flow.
play
blocks until finishednon-blocking with background
Plays an audio file fetched from a URL. WAV and MP3 are supported;
media is capped at 20 MiB and fetched through the same
SSRF-guarded client as documents. With background: true it
becomes non-blocking ambient audio: it loops on the call leg and
automatically ducks under any foreground audio —
say verbs, gather prompts, limit warnings, REST-initiated
leg TTS — then fades back. One background playback per call; starting
another replaces it.
Instead of an http(s) URL, url (and dial's
holdMusic) accepts a media library reference:
media:<sid> or media:<title> names a
file uploaded to your account's media library
(POST /v1/accounts/{sid}/media). Library files play from
local disk — no fetch, no SSRF checks, no size cap — and are
pre-transcoded to the call pipeline format at upload time. Titles are
unique per account (case-insensitive), so a title reference is stable
until you rename the file; the SID form survives renames.
| Field | Type | Default | Description |
|---|---|---|---|
| url | string | required | http(s) URL of a WAV or MP3 file, or a media: library reference (omit with action: "stop"). |
| loop | int | 1 | Repetitions, 1–10 (foreground only — background loops until stopped). |
| background | bool | false | Non-blocking looped ambient audio with ducking; execution continues immediately. |
| action | string | — | "stop" ends the current background playback. |
| duck | object | — | {level, fadeInMs, fadeOutMs}: gain under foreground audio (default 0.3 — a 70% reduction), recovery ramp (default 600 ms), duck-down ramp (default 150 ms). |
{ "verb": "play", "url": "https://cdn.example.com/chime.mp3" }
// from the account's media library, by title or by SID:
{ "verb": "play", "url": "media:welcome.mp3" }
{ "verb": "play", "url": "media:MFa1b2c3d4e5f60718293a4b5c6d7e8f90" }
// ambient music that ducks to 30% whenever anything speaks:
{ "verb": "play", "url": "https://cdn.example.com/ambient.mp3",
"background": true,
"duck": { "level": 0.3, "fadeInMs": 600, "fadeOutMs": 150 } }
// later:
{ "verb": "play", "background": true, "action": "stop" }
pause
blocks until finishedWaits silently before the next verb.
| Field | Type | Default | Description |
|---|---|---|---|
| length | int | 1 | Seconds to wait, 1–300. |
{ "verb": "pause", "length": 2 }
gather
blocks until digits/timeouttransfers control
Collects DTMF digits from the caller, optionally playing a prompt that
the first digit interrupts. Collected digits are POSTed to
actionUrl as a new document fetch. A timeout with no
input falls through to the next verb in the current document — put a
retry say or a fallback after the gather.
| Field | Type | Default | Description |
|---|---|---|---|
| numDigits | int | unlimited | Finish after N digits (1–32). |
| finishOnKey | string | — | Digit that ends collection (0–9, *, #); not included in the result. |
| timeout | int | 5 | Seconds to wait for the first digit (1–60). |
| interDigitTimeout | int | = timeout | Seconds allowed between digits (1–60). |
| actionUrl | string | current doc URL | Where digits are POSTed. In a stored (inline) flow there is no current URL — set it explicitly, use branches, or digits are dropped. |
| branches | object | — | Local jumps (routines-form documents): collected digits (exact match), timeout, default → routine name. Branches win over actionUrl; no fetch. |
| prompt | verb[] | — | Up to 10 nested say/play/pause verbs, interrupted by the first digit (type-ahead skips them entirely). |
| input | string | dtmf | dtmf or speech. Speech mode captures one spoken utterance instead of digits: the transcript lands in {{call.speechResult}}, routes via the speech/timeout branches, and rides action callbacks as speech_result. Prompts play in full (no speech barge-in). STT failures degrade to the timeout path. |
| credential | string | — | Speech only (required): credential:<name> — the OpenAI API key in the tenant vault backing transcription. |
| language | string | auto | Speech only: transcription language hint (de, en-US). |
{
"verb": "gather",
"numDigits": 1,
"timeout": 8,
"actionUrl": "https://app.example.com/ivr/choice",
"prompt": [
{ "verb": "say", "text": "For sales press 1. For support press 2." }
]
}
record
blocks until stopoptional transfer
Records the call audio until finishOnKey,
maxLength, or the call ends. The recording file is
finalized and uploaded asynchronously — the
recording.completed webhook carries the media URL once
ready. With actionUrl set, the recording SID is POSTed
there when recording stops.
| Field | Type | Default | Description |
|---|---|---|---|
| maxLength | int | 300 | Maximum recording length in seconds (1–3600). |
| finishOnKey | string | — | Digit that stops the recording (0–9, *, #). |
| playBeep | bool | false | Play a beep before recording starts. |
| actionUrl | string | — | POSTed recording_sid after stop; omitted = continue with the next verb. |
{
"verb": "record",
"playBeep": true,
"maxLength": 120,
"finishOnKey": "#"
}
dial
blocks until leg endsoptional transfer
Bridges a second PSTN leg into the call: voxio dials to,
and on answer both parties hear each other. The verb blocks until the
child leg ends (either side hangs up) or it fails/times out before
answer. The outcome is POSTed to actionUrl when set.
| Field | Type | Default | Description |
|---|---|---|---|
| to | string | required | E.164 destination number. |
| from | string | origin/group outbound number, else trunk default | Caller ID for the child leg. Without it the leg presents the calling target's outbound_number (else its group's, else the group that produced the leg), only over trunks that may present it, and otherwise the trunk default. |
| trunkSid | string | account routing | Explicit trunk for the child leg. |
| timeout | int | 30 | Seconds to wait for answer (1–600); the bridge itself has no time limit. |
| actionUrl | string | — | POSTed dial_call_sid + dial_call_status (completed, busy, no-answer, failed, screen-timeout). |
| branches | object | — | Local jumps (routines-form documents): dial status or default → routine name. Branches win over actionUrl; no fetch. |
| screen | object | — | Answer screening / mailbox detection: {say, voice, digit, timeout, loop, repeatDelay}. The answered leg is held out of the room, hears say (repeated loop× — default 2, with an optional repeatDelay seconds of silence between repetitions — default 0) on its own audio path, and must press digit (default 1) within timeout seconds (default 12). Pressing the digit cuts the prompt (and any pending delay) immediately. Voicemails never press, so the leg is hung up and the dial ends with status screen-timeout. Only after the digit does the leg join the room — and a limit with startOn: "bridge" starts its clock. |
| holdMusic | string | — | Audio URL or media: library reference looped on the parent leg from dial start until the child bridges (or the dial ends) — the child never hears it; during screening it hears the screen prompt on its own path. Ducks under limit warnings. Failure to load degrades to silence, never fails the dial. |
{
"verb": "dial",
"to": "+4915112345678",
"timeout": 25,
"actionUrl": "https://app.example.com/ivr/after-dial"
}
session block
document-level, not a verb
Paid-minute limits and webhook routing are configured in the
top-level session block of a routines-form
document ({"session": {…}, "routines": {…}}) — there is no
limit verb. A session describes one billable conference
(one room). id is echoed in every webhook (payload field +
X-Voxio-Session header); callbackUrl /
callbackToken receive all session.* events and
branch decisions, signed with X-Voxio-Signature,
independent of the account webhook configuration — so one playbook is
fully self-describing.
| Field | Type | Default | Description |
|---|---|---|---|
| id | string | required | Platform correlation key, echoed in every session.* webhook. |
| callbackUrl | string | required | Receives all session.* webhooks and branch decisions. |
| callbackToken | string | required | HMAC signing secret for those webhooks (X-Voxio-Signature). |
| limit | object | — | Optional active-time cap (paid minutes); see below. |
| host, participants, connect_priority, holdMusic, voice | — | — | Declarative session orchestration — bring up host + participants and bridge them automatically. See docs/voxml-session-orchestration.md. |
The nested session.limit caps active (≥2-party)
time. At each warning offset voxio speaks the announcement only to
the configured leg; at zero the onExpiry action runs.
Pressing extendDigit (on either leg) emits
session.extension_requested to callbackUrl —
the clock keeps running; the platform extends via
PATCH /v1/accounts/{account_sid}/calls/{call_sid}/limit
with a new absolute max_minutes. Re-fetching a
document re-arms (updates) the running limit; it never stacks timers.
The extend digit is reserved: gather verbs never see it.
limit field | Type | Default | Description |
|---|---|---|---|
| maxMinutes | int | required | Active-time budget (1–480). |
| warnings | array | — | Up to 5 of {atRemainingSeconds, say, voice, target}; target names a participant object, with parent (the flow's own leg, default), child (the dial-bridged leg), and both valid for the serial dial topology (caller/advisor accepted as aliases). Offsets that no longer fit after an extension are skipped, never fired late. |
| extendDigit | string | — | DTMF key that requests an extension (one of 0-9, *, #). |
| onExpiry | string | end_session | end_session hangs up all legs and ends the room; drop_leg hangs up only the dial-bridged leg and the caller's flow continues (falls back to end_session when no leg is bridged). |
{
"session": {
"id": "abc123",
"callbackUrl": "https://app.example.com/sessions/abc123",
"callbackToken": "whsec_…",
"limit": {
"maxMinutes": 30,
"warnings": [
{ "atRemainingSeconds": 300, "say": "Your call ends in five minutes. Press 5 to extend.", "target": "caller" }
],
"extendDigit": "5",
"onExpiry": "end_session"
}
},
"routines": { "main": [ /* verbs */ ] }
}
Webhook events: session.started (the billable clock
started), session.limit_warning,
session.extension_requested (carries a per-cycle
nonce — the platform's idempotency key; repeat presses
re-emit the same nonce), session.extended,
session.expired, and session.ended — the
terminal accounting event, emitted exactly once per session whatever
the end cause, with started_at, ended_at,
billable_seconds (capped at the deadline) and
reason (completed | expired |
never_started). Bill from session.ended,
reconciled against the CDRs; use the other events for real-time UX
only. Each payload includes call_sid,
room_sid, child_call_sid,
remaining_seconds, and deadline.
redirect
transfers control
Transfers control to a new document fetched from url. The
remaining verbs in the current document are discarded. Stored inbound
flows can use this to hand off to a dynamic application URL.
| Field | Type | Default | Description |
|---|---|---|---|
| url | string | required | http(s) URL returning the next document. |
{ "verb": "redirect", "url": "https://app.example.com/flow/step2" }
ask
blocks until decidedoptional transfer
Webhook decision point: POSTs the call state (signed) to your URL
mid-call; the JSON response may patch vars, splice verbs
("actions"), and/or jump ("routine"). The mandatory onError verbs
run on timeout, connection error, non-2xx, or an invalid response —
your server being down never strands the caller. Requests are signed
with the session callbackToken (plus
X-Voxio-Session) when the document has a session block,
otherwise with the account webhook secret. No retries; loop or retry
via routine jumps.
| Field | Type | Default | Description |
|---|---|---|---|
| url | string | required | http(s) decide-endpoint; templatable ({{ }}). |
| timeout | int | 4 | Seconds to wait for the response (1–10). |
| include | string[] | [] | Payload extras: digits, data, vars. |
| onError | verb[] | required | Fallback verbs; control falls through after them unless they jump or hang up. |
{ "verb": "ask", "url": "https://agent.example/decide", "include": ["digits", "vars"],
"onError": [ { "verb": "say", "text": "One moment please." } ] }
script
blocks until finishedoptional transfer
In-playbook JavaScript: runs your source inside voxio (goja) with
vars read/write, read-only data/call/session,
fetch() (GET/POST/PUT, ≤4 requests × 4 s, SSRF-guarded),
credential("name") (the account vault), and
log(). The return value names one of the declared
exits (a routine); returning nothing falls
through. Budgets: 5 s wall per invocation, 50 ms CPU slices, ≤32 KB
source, 60 s cumulative per call — exhaustion runs
onError, never a hung call. Ideal for wrapping legacy
APIs and routing math with no dependency on your server being up.
| Field | Type | Default | Description |
|---|---|---|---|
| source | string | required | JavaScript; top-level return/await allowed. |
| exits | string[] | required | Declared jump targets; each must name an existing routine. |
| onError | verb[] | required | Fallback verbs (throw, budget, bad exit). |
{ "verb": "script",
"source": "const r = fetch('https://api.example/slots'); vars.slots = r.json().slots; return vars.slots.length ? 'offer' : 'none';",
"exits": ["offer", "none"],
"onError": [ { "verb": "say", "text": "Please hold." } ] }
skill
blocks until finished
Runs one tool of an account skill (the same sandboxed JavaScript tools
a connect agent can call) and stores its data as
vars.<saveAs>. Later verbs use it in templates, for
example a personal connect greeting
({{vars.caller.name}}), or branch on it with
goto when. Host allowlist, vault secrets, rate
limits and the execution log apply as in an agent call. The built-in
skill voxio-time (tools now, date_info)
works in every account, e.g. for {{vars.now.greeting}}.
| Field | Type | Default | Description |
|---|---|---|---|
| skill | string | required | Skill name of the account. |
| tool | string | required | Tool name from the skill's manifest. |
| args | object | {} | Tool arguments (≤8 KB); string values are templates and stay strings. |
| saveAs | string | — | Variable that receives the tool's data; a null result removes it. |
| timeout | integer | 5 | Seconds, 1–10. |
| onError | verb[] | — | Fallback verbs (skill missing or paused, bad args, tool error, limit, timeout). Without it the next verb runs. |
{ "verb": "skill", "skill": "crm", "tool": "find_customer",
"args": { "phone": "{{call.from}}" }, "saveAs": "caller",
"onError": [ { "verb": "goto", "routine": "unknown_caller" } ] }
connect
blocks until the conversation endsoptional transfer
Holds the leg in a realtime AI agent conversation. The
agent block names a provider (openai,
google, elevenlabs, pipeline, or
custom — your own wss endpoint speaking
openai-realtime), the provider API key as a
credential:<name> vault reference, and the system
prompt. The outcome routes to branches
(completed, transfer, error);
declaring a transfer branch is what allows the model to
hand the caller off. The mandatory onError verbs run when
the provider session cannot be established or dies — a provider outage
never strands the caller.
| Field | Type | Default | Description |
|---|---|---|---|
| agent.provider | string | required | openai | google | elevenlabs | pipeline | custom. |
| agent.credential | string | required | credential:<name> — the provider API key in the tenant vault. |
| agent.instructions | string | required* | System prompt; templatable. Optional for elevenlabs (the provider-side agent config carries it) and custom. |
| agent.model / agent.voice / agent.language | string | adapter default | Model id, provider voice (pipeline: a voxio named voice), BCP-47 language tag. |
| agent.greeting | string | — | Spoken verbatim at session start; templatable. |
| agent.agentId | string | — | elevenlabs only (required there): the provider-hosted agent id. |
| agent.url / agent.protocol | string | — | custom only (required there): your wss endpoint and the protocol it speaks (openai-realtime). |
| agent.sttCredential | string | — | pipeline only (required there): the STT provider key. |
| agent.builtinTools | string[] | [] | Opt-in call-control tools: hangup, send_digits, end_session. transfer is implied by a transfer branch. |
| agent.tools | object[] | [] | Your webhook tools: {name, description, url, parameters, timeout} — the model calls your HTTPS endpoint mid-conversation. |
| agent.skills | string[] | [] | Names of your account's skills (max 8). Their tools are offered to the model as <skill>__<tool>; voxio runs them in a sandbox and makes the HTTP calls itself. Missing or paused skills are skipped. Every agent also gets the built-in time tools voxio_time__now and voxio_time__date_info (date, time, weekday, greeting by time of day, astronomical season, public holidays; defaults from the account's country) without listing them. |
| agent.maxMinutes | int | 30 | Hard cap on the conversation (1–480). |
| branches | object | — | completed | transfer | error → routine name. |
| onError | verb[] | required | Fallback verbs when the agent session fails. |
{ "verb": "connect",
"agent": { "provider": "openai", "credential": "credential:openai-prod",
"instructions": "You are the booking assistant for {{data.company}}.",
"builtinTools": ["hangup"] },
"branches": { "transfer": "human_agent" },
"onError": [ { "verb": "say", "text": "One moment, connecting you." } ] }
stream
foreground blocksnon-blocking with background
Streams the leg's raw audio to your WebSocket endpoint as
voxio-stream-v1 frames — the Media Streams analog for when
you want frames, not an agent contract. Foreground (default) is
bidirectional and holds the leg like connect; with
background: true it is unidirectional (audio out only) and
falls through — the flow continues while the stream runs, one per leg,
ended by {"verb": "stream", "action": "stop"} or call
teardown. The mandatory onError runs when the endpoint is
unreachable or dies.
| Field | Type | Default | Description |
|---|---|---|---|
| url | string | required | Your ws(s) endpoint; templatable. Not allowed with action: "stop". |
| action | string | — | Omit to start; "stop" ends the leg's background stream (no other fields). |
| credential | string | — | Optional credential:<name> — sent as an Authorization bearer. |
| background | bool | false | Unidirectional; the flow continues immediately. |
| maxMinutes | int | 30 | Foreground cap (1–480); background streams are bounded by their leg. |
| branches | object | — | Foreground only: completed | error → routine name. |
| onError | verb[] | required | Fallback verbs when the endpoint fails. |
{ "verb": "stream", "url": "wss://ears.example.com/calls", "background": true,
"onError": [] }
enqueue
blocks until dequeued/timeoutoptional transfer
Parks the caller in an account queue (created via
POST /v1/accounts/{sid}/queues) and loops the
waitPlaybook — hold music, position announcements,
press-1 gathers, all ordinary VoxML — until an agent flow
dequeues the member, REST dispatch delivers a bridge
playbook, the queue's maxWaitSeconds elapses, or the
caller hangs up. Inside the wait loop the {{queue.*}}
namespace is live: name, position,
depth, waitSeconds,
estimatedWaitSeconds/estimatedWaitMinutes.
Hold time is leg time, never billed session active-time.
| Field | Type | Default | Description |
|---|---|---|---|
| queue | string | required | Queue name (lowercase alphanumeric, -, _). |
| waitPlaybook | verb[] | [] | Looped while waiting; say | play | pause | gather only, max 16 verbs. |
| branches | object | — | bridged | timeout | full | hangup → routine name. |
{ "verb": "enqueue", "queue": "support",
"waitPlaybook": [
{ "verb": "say", "text": "You are number {{queue.position}} in line." },
{ "verb": "play", "url": "media:hold-music" } ],
"branches": { "timeout": "voicemail" } }
dequeue
blocks until the conversation endsoptional transfer
The agent-pull side: bridges the longest-waiting member of the queue
into this flow's own room and holds until the conversation ends. With
no waiting member the verb takes the empty branch (or
falls through) immediately. Alternatively, REST dispatch
(POST /queues/{name}/members/{call_sid}/dequeue) pushes a
bridge playbook onto a specific member's leg without an agent flow.
| Field | Type | Default | Description |
|---|---|---|---|
| queue | string | required | Queue name. |
| branches | object | — | bridged | empty → routine name. |
{ "verb": "dequeue", "queue": "support",
"branches": { "empty": "no_callers" } }
hangup
ends the flow
Ends the call immediately. Reaching the end of a document without a
transfer of control behaves the same way, so a trailing
hangup is optional but recommended for clarity. No fields.
{ "verb": "hangup" }
Object-layer verbs
object documents only
Playbooks with a top-level objects block (declared calls,
rooms, playbacks, hunts, streams, agents — see the session
orchestration spec) get six additional verbs that steer the objects
themselves. In serial/routines-form documents they are invalid.
| Verb | Fields | Description |
|---|---|---|
| join | room (optional) | Moves the issuing object's leg into a room. Bare join: the session's conference room; a named room joins that room object. Blocks until added. |
| leave | — | Removes the leg from its current room, back to a solo mixer. |
| start | object (required) | Starts a declared object whose trigger is manual (or has not fired yet). No-op if already started. |
| stop | object (required) | Stops another object: a playback ends, a room ends, a call hangs up. |
| goto | routine (required), when | Transfers the issuing object's execution to a routine. With when (a {{ }} expression, e.g. {{vars.caller.credit > 10}}) the jump only happens when the result is truthy; otherwise the next verb runs. |
| end_session | — | Ends the whole session now: billing banks to this moment, session.ended fires, every leg and the room are torn down. Also the target of limit.onExpiry: "end_session". |
{ "verb": "start", "object": "advisor" }
{ "verb": "join" }
{ "verb": "end_session" }
Limits
| Limit | Value |
|---|---|
| Verbs per document | 50 |
| Document size (stored or fetched) | 64 KiB |
| Documents per call (fetch chain) | 10 (server-configurable) |
say text length | 4096 bytes |
loop repetitions | 10 |
pause length | 300 s |
gather digits / timeouts | 32 digits / 60 s |
gather prompt verbs | 10 |
record max length | 3600 s |
dial answer timeout | 600 s (default 30 s) |
POST /calls timeout (answer deadline) | 600 s (default: none) |
limit budget / warnings | 480 min / 5 |
| Sections per document / total verbs | 32 / 200 |
| Section branch jumps per document | 50 |
play media size | 20 MiB |
Examples
Announcement (stored on an inbound number — no app server)
[
{ "verb": "say", "text": "Our office is closed. Please call again Monday from 9 AM." },
{ "verb": "hangup" }
]
Call forwarding with voicemail fallback
[
{ "verb": "say", "text": "Connecting you now." },
{ "verb": "dial", "to": "+4915112345678", "timeout": 20 },
// dial without actionUrl: execution continues here when the leg ends
{ "verb": "say", "text": "Please leave a message after the beep." },
{ "verb": "record", "playBeep": true, "maxLength": 120, "finishOnKey": "#" }
]
Paid-minutes consultation — one self-contained plan (routines + limit + screened dial + hold music)
// POST /v1/accounts/{as}/calls — the advisor is the parent leg ("to"), // the customer is the screened dial child. Every outcome is in the plan; // voxio never fetches anything mid-call. { "to": "+4930120849042", "from": "+4930120849000", "playbook": { "session": { "id": "sess_8f3a2b91", "callbackUrl": "https://app.example.com/voxio/sessions/sess_8f3a2b91", "callbackToken": "whsec_…" }, "routines": { "main": [ { "verb": "gather", "numDigits": 1, "timeout": 15, "branches": { "1": "accepted", "2": "declined", "timeout": "no_input", "default": "no_input" }, "prompt": [ { "verb": "say", "text": "A customer is waiting. Press 1 to accept, 2 to decline." } ] } ], "accepted": [ { "verb": "limit", "maxMinutes": 15, "startOn": "bridge", "warnings": [ { "atRemainingSeconds": 300, "say": "Five minutes remaining. Press 5 to top up.", "target": "child" }, { "atRemainingSeconds": 120, "say": "The session ends in two minutes.", "target": "parent" } ], "extendDigit": "5" }, { "verb": "say", "text": "Calling the customer now." }, { "verb": "dial", "to": "+4917612345678", "timeout": 30, "holdMusic": "https://cdn.example.com/hold-loop.mp3", "screen": { "say": "Your consultation begins shortly. Press 1 to start.", "digit": "1", "timeout": 15 }, "branches": { "completed": "done", "screen-timeout": "mailbox", "default": "unreachable" } } ], "done": [ { "verb": "say", "text": "The session has ended. Thank you." }, { "verb": "hangup" } ], "mailbox": [ { "verb": "say", "text": "The customer could not be reached in person." }, { "verb": "hangup" } ], "unreachable": [ { "verb": "say", "text": "The customer could not be reached." }, { "verb": "hangup" } ], "declined": [ { "verb": "say", "text": "Understood. The request was declined." }, { "verb": "hangup" } ], "no_input": [ { "verb": "say", "text": "No input received. Goodbye." }, { "verb": "hangup" } ] } } } // The advisor hears hold music while the customer rings + screens (it ducks // under limit warnings); the customer's ring + screening time costs nothing — // the clock starts (session.started) when they press 1 and bridge. Every // branch decision arrives as a playbook.branch webhook on the session callback, // every event carries session_id sess_8f3a2b91 (payload + X-Voxio-Session). // On "press 5": session.extension_requested → charge → // PATCH /v1/accounts/{as}/calls/{call_sid}/limit {"max_minutes": 25, "nonce": "CA…-1"} // Settlement: bill from session.ended (billable_seconds), reconciled // against the CDRs.
IVR menu (stored flow handing digits to an app)
// Stored on the number: [ { "verb": "gather", "numDigits": 1, "actionUrl": "https://app.example.com/ivr", "prompt": [ { "verb": "say", "text": "For sales press 1. For support press 2." } ] }, { "verb": "say", "text": "We did not receive a selection. Goodbye." } ] // https://app.example.com/ivr receives {"digits":"1", …} and returns: [ { "verb": "say", "text": "Connecting you to sales." }, { "verb": "dial", "to": "+4930555111" } ]
Outbound notification call
// POST /v1/accounts/{as}/calls { "to": "+4915112345678", "from": "+4930123456", "timeout": 25, // stop ringing after 25 s → no_answer "playbook_url": "https://app.example.com/notify" } // https://app.example.com/notify returns: [ { "verb": "say", "text": "Your delivery arrives today between 2 and 4 PM.", "loop": 2 }, { "verb": "hangup" } ]
Error handling
- Invalid document (bad JSON, unknown verb/field, limit exceeded): the document is rejected, a
playbook.fetch_failedwebhook fires, and the call is hung up. Stored documents are validated when you save them, so this only happens to fetched documents. - Fetch failure (timeout, non-200 after retry):
playbook.fetch_failedwebhook, call hung up. - Verb failure (e.g.
saywithout TTS configured, unplayable media): the flow stops and the call is hung up; the reason is logged server-side. - Caller hangs up: the executor aborts immediately, in-flight verbs are cancelled, and the room is torn down.
- Every flow emits
playbook.startedandplaybook.completedwebhooks; failures additionally emitplaybook.fetch_failed.