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 from playbook_url on an outbound call).
  • Control transfers — a redirect, or the actionUrl of a gather/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 hangup verb, 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).
  • gather branch keys: the collected digits (exact match), timeout (no input), default (any other digits). dial branch keys: completed, busy, no-answer, failed, screen-timeout, default.
  • Branches win over actionUrl; an unmatched outcome falls back to actionUrl (when set) or falls through.
  • Every branch taken emits a fire-and-forget playbook.branch webhook (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.id is attached to all webhooks for the call (payloads + X-Voxio-Session header) and to voxio's logs — grep by your ID, not voxio's SIDs.
  • A limit verb inherits callbackUrl/callbackToken from 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 session block. When the first document has none (e.g. an inbound number's stored redirect), the first fetched document that carries one arms it; a later document never re-arms or replaces an armed session.
Static or dynamic — your choice. A fully self-contained routines-form document needs no application server at all while the call is live: your platform's only runtime duties are receiving webhooks (retried) and the optional limit-extension 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>), and Reject (approximated as hangup — voxio has no pre-answer reject).
  • Twilio defaults are materialized: a Gather without finishOnKey gets "#"; a Record without playBeep beeps — migrated documents behave as they did on Twilio.
  • Ignored attributes (no-ops under voxio's execution model): method everywhere (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 verbs enqueue, connect, and stream are native VoxML; switch the document to JSON to use them), Dial nouns other than a bare <Number>, Gather input="speech", Play digits, Record transcribe, SSML markup inside Say, loop="0" (infinite), unknown attributes (typo safety), and relative action URLs (voxio requires absolute URLs).
  • Routines, branches, the session block, background audio, and dial screening have no TwiML syntax — switch a document to JSON to use them. Translation is per-document: a TwiML document may Redirect to 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.

Flows vs. webhooks. Webhooks are voxio's one-way event notifications (call answered, recording completed, …) sent to your account's webhook URL. Flows control the call itself. They are independent: a stored inbound flow runs fine for an account that has no webhook configured.
Answer deadline vs. call duration. Three different clocks, easy to confuse:
  • timeout on POST /calls — seconds the outbound call you are creating may ring. Still unanswered when it elapses, voxio cancels the attempt: the call ends with hangup cause no_answer and duration_seconds 0. Omitted or 0 means 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 status no-answer; the webhook is call.ended (every call ends with exactly one, answered or not). Read status and hangup_cause on its payload — the full Call — to tell outcomes apart.
  • timeout on the dial verb — the same thing for the second leg a flow bridges in (default 30 s). Reaching it yields dial status no-answer, which your branches and actionUrl can act on.
  • maxMinutes in the limit block — the budget for an answered session, with warnings, onExpiry, and mid-call extension via PATCH /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.

VerbCallback fieldsactionUrl omitted
gatherdigitsStored flows: digits are dropped, execution continues with the next verb. URL-fetched flows: digits are re-POSTed to the current document URL.
recordrecording_sidExecution continues with the next verb.
dialdial_call_sid, dial_call_statusExecution 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 from POST /sessions and 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:

EndpointBodyReturns
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 timeout separately answers "did they connect in time?" (no-answer when not). Re-mint a lost/leaked token with POST /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 reconnectWindow seconds; billing occupancy pauses the session clock meanwhile. Only when the window lapses does ended fire.
  • Lifecycle events: participant.answered (ICE/DTLS up), participant.reconnecting/reconnected, participant.failed (never connected), participant.ended — signed to the session's callbackUrl. Each payload names the participant, its leg_sid and the endpoint it 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}/playbacks plays a media-library file onto it, POST /channels/{sid}/tts speaks 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 finished

Speaks text to the caller via TTS (ElevenLabs). Synthesized audio is cached server-side, so repeated phrases replay instantly without re-synthesis.

FieldTypeDefaultDescription
textstringrequiredThe text to speak. Max 4096 bytes.
voicestringserver defaultA voxio voice name from the account's voice registry (resolves to its TTS provider/model). Omit to use the session or system default.
loopint1Repetitions, 1–10.
{ "verb": "say", "text": "Your order has shipped.", "loop": 2 }
Requires TTS. 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.

FieldTypeDefaultDescription
urlstringrequiredhttp(s) URL of a WAV or MP3 file, or a media: library reference (omit with action: "stop").
loopint1Repetitions, 1–10 (foreground only — background loops until stopped).
backgroundboolfalseNon-blocking looped ambient audio with ducking; execution continues immediately.
actionstring—"stop" ends the current background playback.
duckobject—{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 finished

Waits silently before the next verb.

FieldTypeDefaultDescription
lengthint1Seconds 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.

FieldTypeDefaultDescription
numDigitsintunlimitedFinish after N digits (1–32).
finishOnKeystring—Digit that ends collection (0–9, *, #); not included in the result.
timeoutint5Seconds to wait for the first digit (1–60).
interDigitTimeoutint= timeoutSeconds allowed between digits (1–60).
actionUrlstringcurrent doc URLWhere digits are POSTed. In a stored (inline) flow there is no current URL — set it explicitly, use branches, or digits are dropped.
branchesobject—Local jumps (routines-form documents): collected digits (exact match), timeout, default → routine name. Branches win over actionUrl; no fetch.
promptverb[]—Up to 10 nested say/play/pause verbs, interrupted by the first digit (type-ahead skips them entirely).
inputstringdtmfdtmf 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.
credentialstring—Speech only (required): credential:<name> — the OpenAI API key in the tenant vault backing transcription.
languagestringautoSpeech 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.

FieldTypeDefaultDescription
maxLengthint300Maximum recording length in seconds (1–3600).
finishOnKeystring—Digit that stops the recording (0–9, *, #).
playBeepboolfalsePlay a beep before recording starts.
actionUrlstring—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.

FieldTypeDefaultDescription
tostringrequiredE.164 destination number.
fromstringorigin/group outbound number, else trunk defaultCaller 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.
trunkSidstringaccount routingExplicit trunk for the child leg.
timeoutint30Seconds to wait for answer (1–600); the bridge itself has no time limit.
actionUrlstring—POSTed dial_call_sid + dial_call_status (completed, busy, no-answer, failed, screen-timeout).
branchesobject—Local jumps (routines-form documents): dial status or default → routine name. Branches win over actionUrl; no fetch.
screenobject—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.
holdMusicstring—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.

FieldTypeDefaultDescription
idstringrequiredPlatform correlation key, echoed in every session.* webhook.
callbackUrlstringrequiredReceives all session.* webhooks and branch decisions.
callbackTokenstringrequiredHMAC signing secret for those webhooks (X-Voxio-Signature).
limitobject—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 fieldTypeDefaultDescription
maxMinutesintrequiredActive-time budget (1–480).
warningsarray—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.
extendDigitstring—DTMF key that requests an extension (one of 0-9, *, #).
onExpirystringend_sessionend_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.

FieldTypeDefaultDescription
urlstringrequiredhttp(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.

FieldTypeDefaultDescription
urlstringrequiredhttp(s) decide-endpoint; templatable ({{ }}).
timeoutint4Seconds to wait for the response (1–10).
includestring[][]Payload extras: digits, data, vars.
onErrorverb[]requiredFallback 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.

FieldTypeDefaultDescription
sourcestringrequiredJavaScript; top-level return/await allowed.
exitsstring[]requiredDeclared jump targets; each must name an existing routine.
onErrorverb[]requiredFallback 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}}.

FieldTypeDefaultDescription
skillstringrequiredSkill name of the account.
toolstringrequiredTool name from the skill's manifest.
argsobject{}Tool arguments (≤8 KB); string values are templates and stay strings.
saveAsstring—Variable that receives the tool's data; a null result removes it.
timeoutinteger5Seconds, 1–10.
onErrorverb[]—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.

FieldTypeDefaultDescription
agent.providerstringrequiredopenai | google | elevenlabs | pipeline | custom.
agent.credentialstringrequiredcredential:<name> — the provider API key in the tenant vault.
agent.instructionsstringrequired*System prompt; templatable. Optional for elevenlabs (the provider-side agent config carries it) and custom.
agent.model / agent.voice / agent.languagestringadapter defaultModel id, provider voice (pipeline: a voxio named voice), BCP-47 language tag.
agent.greetingstring—Spoken verbatim at session start; templatable.
agent.agentIdstring—elevenlabs only (required there): the provider-hosted agent id.
agent.url / agent.protocolstring—custom only (required there): your wss endpoint and the protocol it speaks (openai-realtime).
agent.sttCredentialstring—pipeline only (required there): the STT provider key.
agent.builtinToolsstring[][]Opt-in call-control tools: hangup, send_digits, end_session. transfer is implied by a transfer branch.
agent.toolsobject[][]Your webhook tools: {name, description, url, parameters, timeout} — the model calls your HTTPS endpoint mid-conversation.
agent.skillsstring[][]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.maxMinutesint30Hard cap on the conversation (1–480).
branchesobject—completed | transfer | error → routine name.
onErrorverb[]requiredFallback 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.

FieldTypeDefaultDescription
urlstringrequiredYour ws(s) endpoint; templatable. Not allowed with action: "stop".
actionstring—Omit to start; "stop" ends the leg's background stream (no other fields).
credentialstring—Optional credential:<name> — sent as an Authorization bearer.
backgroundboolfalseUnidirectional; the flow continues immediately.
maxMinutesint30Foreground cap (1–480); background streams are bounded by their leg.
branchesobject—Foreground only: completed | error → routine name.
onErrorverb[]requiredFallback 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.

FieldTypeDefaultDescription
queuestringrequiredQueue name (lowercase alphanumeric, -, _).
waitPlaybookverb[][]Looped while waiting; say | play | pause | gather only, max 16 verbs.
branchesobject—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.

FieldTypeDefaultDescription
queuestringrequiredQueue name.
branchesobject—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.

VerbFieldsDescription
joinroom (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.
startobject (required)Starts a declared object whose trigger is manual (or has not fired yet). No-op if already started.
stopobject (required)Stops another object: a playback ends, a room ends, a call hangs up.
gotoroutine (required), whenTransfers 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

LimitValue
Verbs per document50
Document size (stored or fetched)64 KiB
Documents per call (fetch chain)10 (server-configurable)
say text length4096 bytes
loop repetitions10
pause length300 s
gather digits / timeouts32 digits / 60 s
gather prompt verbs10
record max length3600 s
dial answer timeout600 s (default 30 s)
POST /calls timeout (answer deadline)600 s (default: none)
limit budget / warnings480 min / 5
Sections per document / total verbs32 / 200
Section branch jumps per document50
play media size20 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_failed webhook 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_failed webhook, call hung up.
  • Verb failure (e.g. say without 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.started and playbook.completed webhooks; failures additionally emit playbook.fetch_failed.