Skip to main content

Programmable Voice DSL Reference

When you point one of your Devotel phone numbers (DIDs) at an HTTPS answer URL, Orbit turns that DID into a fully programmable inbound call — the Twilio voiceUrl / Vonage answer_url model. On every inbound call Orbit performs a server-side HTTP request to your URL with the call metadata and expects a JSON response describing what to do with the call. That JSON is a list of verbs — the programmable-voice DSL documented here. The verbs your answer_url returns are parsed, validated, and sanitised by sanitizeDslVerbs (apps/api/src/routes/jambonz/voice-dsl-executor.ts) — that function is the source of truth for this contract. The webhook inbound-route handler (buildWebhookVerbs in apps/api/src/routes/jambonz/verb-builder.ts) performs the server-side fetch to your URL and then hands the response body to sanitizeDslVerbs; the other build*Verbs handlers in that file build the platform-managed verb sequences for the non-webhook route types (agent, queue, voicemail, …). The verb shape is pinned as the VoiceDslVerb OpenAPI component (apps/api/src/lib/openapi-schemas.ts).
Outbound calls always terminate through the Devotel softswitch. Every dial verb is routed through Devotel’s wholesale softswitch trunk. You cannot select an arbitrary carrier, Telnyx Call Control, or DIDWW for the outbound leg — any carrier-selection field you return is ignored. Quality, STIR/SHAKEN signing, and PSAP routing are owned by Devotel.

Architecture: JSON verbs, not a VXML server

If you are coming from Twilio TwiML or raw VoiceXML, the mental model carries over with one difference: Orbit does not serve a VXML document to the voice engine. jambonz, the engine under Orbit, consumes the verbs as structured JSON, so your answer_url is the VXML authoring surface: it decides what happens next on every call, and the JSON verb list you return is the call-control document. Orbit validates and sanitises that document, then hands it to the engine exactly as a VXML interpreter would hand a parsed document to a browser. Scripting, branching, and loops live in your answer_url code (any language that can serve HTTPS JSON), not in a document grammar. The mapping is close to one-to-one. A classic VXML menu:
is this verb list in Orbit:
<prompt> maps to say (or play for a hosted audio file), the DTMF grammar to gather with input: ["digits"] and numDigits, and <submit next=…> to actionHook, the URL that receives the collected digit and returns the next verb list. There is no XML to maintain, host, or version: the same serverless function or controller action that would render a VXML document returns JSON instead. The field-by-field correspondence for every verb is in the VoiceXML mappings table below.

The request Orbit POSTs to your answer_url

On each inbound call, Orbit sends an HTTP request to your configured URL. The default method is POST with a JSON body; if you configured GET, the same fields arrive as query-string parameters.

Verifying the request came from Orbit

When your inbound route has a signing secret configured, Orbit signs every request to your answer_url with HMAC-SHA256 so you can confirm it genuinely came from Orbit and was not forged by someone who learned your URL — the Twilio X-Twilio-Signature / Vonage answer_url JWT parity. Two headers carry the signature: Routes provisioned without a signing secret receive unsigned requests (neither header is present), so signing is opt-in and adding a secret never breaks an existing integration. The signed content is the timestamp, a literal ., and the exact request payload Orbit put on the wire:
  • POST — <X-Devotel-Timestamp>.<raw_request_body>, where the raw body is the exact JSON byte-for-byte as received. Hash the raw bytes; do not re-serialize the parsed JSON, or key-order and whitespace differences will make the signature mismatch.
  • GET — <X-Devotel-Timestamp>.<raw_query_string>, where the query string is exactly what Orbit appended to your URL (everything after ?, without the leading ?).
To verify:
  1. Read X-Devotel-Timestamp and reject the request if it is older than a few minutes (for example, 5 minutes) to bound replay.
  2. Compute expected = "sha256=" + HMAC_SHA256(secret, "<timestamp>.<payload>") as hex, using the raw body (POST) or raw query string (GET) as payload.
  3. Compare expected against X-Devotel-Signature with a timing-safe comparison and reject on mismatch.
This answer_url signature is a separate scheme from the customer-webhook HMAC documented in Webhook Security. Both use HMAC-SHA256 keyed on a per-resource secret, but the answer_url request carries the timestamp in its own X-Devotel-Timestamp header and a sha256=<hex> value in X-Devotel-Signature, rather than the combined t=<unix>,v1=<hex> encoding used for webhook deliveries. Verify each with the scheme documented for it.

The response your answer_url returns

Respond with Content-Type: application/json and either an envelope or a bare array of verbs:
A bare array is also accepted:
The verbs run top-to-bottom. If your URL times out (default 5s, hard cap 10s), returns a non-2xx status, returns non-JSON, or returns an empty / malformed array, Orbit emits the route’s configured fallback verbs so the caller never lands on dead air.

Verbs

Each verb object is discriminated by its verb field.

say

Text-to-speech playback to the caller.

play

Play a pre-recorded audio file. The URL must be https:// and is SSRF-validated at configuration time.

gather

Collect DTMF and/or speech input from the caller. This is also the IVR-menu primitive: play your prompt with a preceding say (or play), then branch on the collected digit in the verb list you return from actionHook.

dial

Bridge the caller to an outbound leg. Routed exclusively through the Devotel softswitch trunk — you cannot select a carrier.

record

Capture the caller’s audio to a single recording file — the file-capture primitive (e.g. for a voicemail, a spoken message, or a survey answer). It records the current leg until the caller presses finishOnKey or maxLength seconds elapse, then POSTs the recording metadata to your actionHook. You are responsible for the consent this capture requires — see the consent note below.
Consent for the record verb is your responsibility. A record verb returned from your answer_url is passed through to the call as-is — Orbit does not apply its recording-consent gate to it. That gate governs only Orbit-managed capture: route-level recording (recording_mode) and the platform voicemail flow. When your answer_url drives the call, you own the flow and the compliance obligation. Play any disclosure your jurisdiction requires — for example, US all-party-consent states or EU two-party consent regimes — before you return the record verb.
Recording the whole call is a route setting, not a DSL toggle. To record an entire bridged call, set the inbound route’s recording_mode (all or inbound) — Orbit then records both/inbound legs through the Devotel SIPREC server for the life of the call. There is no boolean record start/stop switch in the DSL: a record field set to true or false is not consumed by the engine and is ignored. Use the record verb above only to capture a discrete clip to a file.

enqueue

Park the caller in a named queue (e.g. for ACD / contact-center routing).

transfer

Hand the call off to another inbound route or destination.
Like dial, a transfer to a PSTN number terminates through the Devotel softswitch trunk — emergency short codes and blocked-prefix destinations are rejected, and the caller hears your fallback instead.

hangup

End the call. Optionally supply a SIP reason surfaced in carrier CDRs.

listen

Fork the live, bidirectional call audio to your own WebSocket endpoint for custom transcription, voice-AI, or analytics — the Twilio Media Streams / Telnyx fork model. Orbit opens a secure WebSocket (wss://) to your url and streams raw audio frames for the duration of the call. The endpoint must be a public host: it is SSRF-validated at admission, so a private or internal address is rejected and the verb is dropped.
listen forks media on the existing call leg — it never originates or terminates a call. Bidirectional audio is injected back into the same leg, so it does not create an outbound carrier leg and is unaffected by the softswitch-termination rule that governs dial.

VoiceXML mappings

For every construct, the verb you return and the VoiceXML element it replaces. Use this table when you port an existing VXML document. The constructs Orbit manages for you, and that you therefore never emit, are listed in Reserved verbs below.

End-to-end example: IVR menu, VXML translated to the DSL

The canonical IVR: “press 1 for sales, press 2 for support.” First the classic two-document VXML form, then the same flow in Orbit. Entry document (what a VXML server would serve on answer):
The same entry point as Orbit verbs (what your answer_url returns):
Line by line: Branch document (what the VXML server would serve after <submit>; in Orbit, what https://app.example.com/voice/menu returns). Your handler reads the POSTed digits field and returns the matching verb list:
for digit 1, and the equivalent list pointing at your support line for digit 2. The dial verb terminates through the Devotel softswitch trunk; no carrier selection is possible or needed. The complete handler, including signature verification, is in Verifying the request came from Orbit; the response envelope is in The response your answer_url returns.

Copy-paste snippets

Both snippets below are the wire contract verbatim: Orbit sanitises the list and hands it to the engine, so the JSON is the executed call-control document — there is no intermediate VXML render step to inspect. Each snippet also shows the classic VoiceXML document it replaces, for readers porting an existing VXML app; the construct-by-construct correspondence is in VoiceXML mappings.

Outbound appointment reminder

Originate the call with POST /calls, passing your answer_url exactly as you would for inbound. This is the same contract as Twilio’s POST /Calls with a Url.
The answer_url returns:
The VoiceXML document this flow replaces (for reference — Orbit executes the JSON above, it never renders this document):
Your actionHook handler records the digit (1 = confirmed, 2 = cancelled, absent = no answer) and returns a closing say plus hangup. In sandbox mode the call resolves completed without touching a carrier, so you can exercise the full round-trip for free; see Test your flow without live minutes.

Record then transcribe a voicemail

Capture the message with record, then fork live audio to your transcription worker with listen. The recording metadata POSTed to your actionHook carries the recording URL; the wss:// stream carries the audio frames your worker transcribes.
The VoiceXML document this flow replaces (for reference — Orbit executes the JSON above, it never renders this document):
Both URLs are validated before the engine uses them: actionHook must be https:// on a public host, and listen must be wss:// on a public host. Anything else (an internal address, plaintext ws://) is dropped so the call degrades safely instead of failing. Remember that consent for record is yours to provide; see the consent warning under record above.

Test your flow without live minutes

The Sandbox and magic numbers simulator keys every outcome on the trailing digit of the destination number, so you can walk a whole flow with no carrier and no charge.
  1. Create an inbound route that points one of your sandbox DIDs at your answer_url, and use a sandbox API key (prefix dv_test_sk_*).
  2. Place a sandbox call to the DID. The call never reaches a carrier and deducts no balance.
  3. Exercise the failure paths by calling the magic numbers instead: trailing 9 maps to no-answer and 8 to busy in the simulator’s catalog, so your no-input and no-answer branch logic gets real traffic.
  4. Assert on your side: your answer_url log shows the POST from Orbit with callSid, from, and to, and your actionHook receives the DTMF digits you sent.
Sandbox voice calls resolve completed today regardless of the trailing digit; the digit table is the simulator’s reference catalog, not live voice behaviour. There is no sandbox DTMF sender: to drive the actionHook branches, answer a sandbox call with a SIP softphone pointed at your sandbox DID, or return the branch verb lists directly from your answer_url while developing.

Limits and pitfalls

  • Answer-URL budget: default timeout 5 seconds, hard cap 10 seconds, on every fetch (first answer and each actionHook round-trip). A slow endpoint means the fallback verbs run instead of your flow; keep prompts pre-rendered and databases warm.
  • No loops in the document: there is no loop verb. Re-prompts and retries are your code’s job: return the same verb list from your actionHook, and cap the attempts yourself (for example, three gather round-trips, then hangup). Also cap chained actionHook hops: each hop is a network round-trip billed against the caller’s patience.
  • Asynchronous verbs: gather and record pause execution until input or timeout; nothing after them runs until they complete. Whatever follows a gather is the no-input path, as in the IVR example above.
  • An empty or malformed response runs the fallback: returning [], non-JSON, or a non-2xx never produces dead air; the route’s fallback (safe-default apology, voicemail, or decline) runs instead.
  • Reserved verbs are dropped: conference and config returned from your answer_url are silently removed; see Reserved verbs.
  • Caller ID on dial: a callerId that is not a syntactically valid E.164 number is dropped and the dialed DID is presented instead; ownership and STIR/SHAKEN attestation happen on the softswitch leg.
  • Hard rejections replace the whole sequence: a dial or transfer to an emergency short code or a blocked prefix does not drop only that verb; the caller hears a polite refusal and the call ends, so a bad number cannot slip past a good prompt.
  • dial exits through the Devotel softswitch only (see the note at the top of this page): carrier-selection fields you return are ignored, SIP-URI targets are rejected, and emergency or blocked-prefix destinations are refused.
  • listen needs a public wss:// host and forks the existing leg only; it never originates or terminates a call.

Reserved verbs

Two verbs are managed by Orbit and cannot be returned from your answer_url:
  • conference — joining a caller into a named conference room. Rooms are created and named by the platform (conference routes, call park, supervisor monitoring), so accepting a room name from a webhook would let one call bridge into another tenant’s or a supervisor’s room. Configure conference routes in the dashboard instead.
  • config — changing in-call engine settings such as pausing or resuming recording. Recording is governed by your route’s recording and consent settings, not by the verb list.
If your answer_url returns either verb, Orbit drops it and continues with the rest of your verb list. Everything you need for a normal call flow is covered by the verbs above.

Failure handling

Orbit fails soft. If your answer_url is slow, unreachable, returns a non-2xx, or returns a malformed body, the configured fallback runs instead — one of:
  • safe-default — a short apology prompt then hangup.
  • voicemail — defer to the route’s voicemail configuration.
  • decline — reject the call with a SIP 603 Decline.
The caller is never left on dead air, and the categorical failure reason is logged for your route analytics without leaking operator topology.