> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Programmable Voice DSL Reference

> Verb contract for the JSON your answer_url returns to control an inbound call

# 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`).

<Note>
  **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.
</Note>

## 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.

```json theme={null}
{
  "callSid": "call_01HZX...",
  "from": "+14155550123",
  "to": "+442071234567",
  "dialedE164": "+442071234567",
  "routeId": "route_01HZX...",
  "organizationId": "org_01HZX..."
}
```

| Field            | Type           | Description                                                  |
| ---------------- | -------------- | ------------------------------------------------------------ |
| `callSid`        | string         | Unique per-call identifier. Stable for the life of the call. |
| `from`           | string \| null | Caller's E.164 number, or `null` when withheld.              |
| `to`             | string         | The dialed E.164 number (your DID). Alias of `dialedE164`.   |
| `dialedE164`     | string         | The dialed E.164 number (your DID).                          |
| `routeId`        | string         | The inbound-route record that pointed this DID at your URL.  |
| `organizationId` | string         | Your Orbit organization ID.                                  |

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

| Header                | Description                                                                                                                 |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `X-Devotel-Timestamp` | Unix timestamp (seconds) when Orbit signed the request. Use it for replay protection.                                       |
| `X-Devotel-Signature` | `sha256=<hex>` — the HMAC-SHA256, hex-encoded, of the signed content described below, keyed by your route's signing secret. |

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.

```javascript theme={null}
import crypto from 'node:crypto';

// `payload` is the raw request body (POST) or the raw query string (GET).
function verifyAnswerUrlRequest(payload, timestamp, signature, secret) {
  if (!timestamp || !signature) return false;

  // Replay protection: reject signatures older than 5 minutes.
  const ageSec = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(ageSec) || ageSec < 0 || ageSec > 5 * 60) return false;

  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${payload}`, 'utf-8')
      .digest('hex');

  const expectedBuf = Buffer.from(expected);
  const signatureBuf = Buffer.from(signature);
  return (
    expectedBuf.length === signatureBuf.length &&
    crypto.timingSafeEqual(expectedBuf, signatureBuf)
  );
}

// In your answer_url handler (Express, using the raw body):
app.post('/voice/answer', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyAnswerUrlRequest(
    req.body.toString('utf-8'), // raw POST body bytes
    req.headers['x-devotel-timestamp'],
    req.headers['x-devotel-signature'],
    'your_route_signing_secret',
  );
  if (!ok) return res.status(401).json({ error: 'Invalid signature' });

  const call = JSON.parse(req.body.toString('utf-8'));
  // Return your verb list...
  res.json({ verbs: [{ verb: 'say', text: 'Thanks for calling.' }] });
});
```

<Note>
  This answer\_url signature is a **separate scheme** from the customer-webhook
  HMAC documented in [Webhook Security](/webhooks/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.
</Note>

## The response your answer\_url returns

Respond with `Content-Type: application/json` and either an envelope or a
bare array of verbs:

```json theme={null}
{
  "verbs": [
    { "verb": "say", "text": "Thanks for calling Acme." },
    { "verb": "dial", "target": [{ "type": "phone", "number": "+14155550123" }] }
  ]
}
```

A bare array is also accepted:

```json theme={null}
[
  { "verb": "say", "text": "Thanks for calling Acme." },
  { "verb": "hangup" }
]
```

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.

```json theme={null}
{ "verb": "say", "text": "Your call is important to us." }
```

| Field  | Type   | Description                      |
| ------ | ------ | -------------------------------- |
| `text` | string | The text to synthesize and play. |

### `play`

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

```json theme={null}
{ "verb": "play", "url": "https://cdn.example.com/greeting.wav" }
```

| Field | Type               | Description                             |
| ----- | ------------------ | --------------------------------------- |
| `url` | string (https URI) | The audio file to stream to the caller. |

### `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`.

```json theme={null}
{
  "verb": "gather",
  "input": ["digits"],
  "numDigits": 1,
  "timeout": 10,
  "actionHook": "https://app.example.com/voice/ivr-step"
}
```

| Field        | Type               | Description                                                         |
| ------------ | ------------------ | ------------------------------------------------------------------- |
| `input`      | string\[]          | Collection modes: `"digits"`, `"speech"`, or both.                  |
| `numDigits`  | integer            | Stop collecting after this many DTMF digits.                        |
| `timeout`    | integer            | Seconds to wait for input before falling through.                   |
| `actionHook` | string (https URI) | URL Orbit POSTs the collected input to; returns the next verb list. |

### `dial`

Bridge the caller to an outbound leg. **Routed exclusively through the
Devotel softswitch trunk (invariant #45)** — you cannot select a carrier.

```json theme={null}
{
  "verb": "dial",
  "callerId": "+442071234567",
  "timeout": 30,
  "target": [{ "type": "phone", "number": "+14155550123" }]
}
```

| Field      | Type      | Description                                                                                                                                                                                                                                                 |
| ---------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `callerId` | string    | Caller ID presented on the outbound leg. Must be a valid E.164 number; a missing or malformed value is dropped and Orbit applies a safe default (the dialed DID). The presented number is signed and attested (STIR/SHAKEN) by Devotel on the outbound leg. |
| `target`   | object\[] | Destinations to dial. Any carrier-selection field is ignored — termination is Devotel-owned.                                                                                                                                                                |
| `timeout`  | integer   | Seconds to ring before treating the leg as no-answer.                                                                                                                                                                                                       |

### `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.

```json theme={null}
{
  "verb": "record",
  "maxLength": 60,
  "finishOnKey": "#",
  "playBeep": true,
  "actionHook": "https://app.example.com/voice/recording-done"
}
```

| Field         | Type               | Description                                                                     |
| ------------- | ------------------ | ------------------------------------------------------------------------------- |
| `maxLength`   | integer            | Maximum recording length in seconds before capture stops.                       |
| `finishOnKey` | string             | DTMF key that ends the recording early (e.g. `"#"`).                            |
| `playBeep`    | boolean            | Play a short beep to the caller before recording begins.                        |
| `actionHook`  | string (https URI) | URL Orbit POSTs the finished recording metadata to; returns the next verb list. |

<Warning>
  **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.
</Warning>

<Note>
  **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.
</Note>

### `enqueue`

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

```json theme={null}
{ "verb": "enqueue", "queue": "support" }
```

| Field   | Type   | Description                           |
| ------- | ------ | ------------------------------------- |
| `queue` | string | The queue name to park the caller in. |

### `transfer`

Hand the call off to another inbound route or destination.

```json theme={null}
{ "verb": "transfer", "destination": "+442071234999" }
```

| Field         | Type   | Description                                           |
| ------------- | ------ | ----------------------------------------------------- |
| `destination` | string | The route, extension, or E.164 number to transfer to. |

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.

```json theme={null}
{ "verb": "hangup", "reason": "Caller served" }
```

| Field    | Type   | Description                            |
| -------- | ------ | -------------------------------------- |
| `reason` | string | Optional human-readable hangup reason. |

### `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.

```json theme={null}
{
  "verb": "listen",
  "url": "wss://stream.example.com/calls",
  "mixType": "stereo",
  "sampleRate": 16000,
  "metadata": { "yourCallRef": "abc-123" },
  "bidirectionalAudio": { "enabled": true }
}
```

| Field                | Type             | Description                                                                                                             |
| -------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `url`                | string (wss URI) | Your WebSocket endpoint. Must be `wss://` — call audio is never sent over a plaintext socket.                           |
| `mixType`            | string           | `mono` (default), `stereo` (caller and far-end on separate channels), or `mixed`.                                       |
| `sampleRate`         | integer          | PCM sample rate: `8000`, `16000`, `24000`, or `48000`.                                                                  |
| `metadata`           | object           | Arbitrary JSON sent as the first WebSocket frame so you can correlate the socket to the call.                           |
| `bidirectionalAudio` | object           | Set `{ "enabled": true }` to stream audio back over the same socket and play it into the caller's leg (voice-AI / TTS). |
| `playBeep`           | boolean          | Play a short beep to the caller before forking begins.                                                                  |
| `wsAuth`             | object           | `{ "username", "password" }` HTTP basic auth presented on the WebSocket upgrade.                                        |

<Note>
  `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`.
</Note>

## 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.
