VoiceML is the JSON format that tells an Edesy call what to do. It is an object with an instructions array; each instruction has a verb and that verb's fields, and the call runs them in order. Your answer URL returns VoiceML, and you can also send it inline as the voiceml field of POST/v1/calls. VoiceML is JSON, not TwiML XML.
{
"instructions": [
{ "verb": "say", "text": "Welcome to Acme." },
{ "verb": "gather", "text": "Press 1 to confirm your order, or 2 to cancel.", "num_digits": 1, "timeout": 8, "action": "https://example.com/edesy/menu" },
{ "verb": "say", "text": "We did not get your answer. Goodbye." },
{ "verb": "hangup" }
]
}
Verbs at a glance#
| Verb | What it does |
|---|---|
say |
Speak text with text-to-speech |
play |
Play an audio file from a URL |
gather |
Collect keypad digits |
record |
Record a message from the caller |
dial |
Connect the call to another phone number |
stream |
Send the call audio to a WebSocket |
redirect |
Continue with instructions from another URL |
enqueue |
Place the caller in a named queue (see the note) |
reject |
Decline an inbound call without answering |
hangup |
End the call |
pause |
Wait in silence |
Rules that apply to every document:
- Instructions run top to bottom. When the last one finishes, the call ends.
- An instruction with an unknown
verbis skipped. If nothing valid is left, the caller hears a short error message and the call ends. - Numbers are JSON numbers (
"timeout": 8), not strings. - Host audio files and callback URLs on public
https://addresses.
say#
Speaks text with text-to-speech.
{ "verb": "say", "text": "आपका ऑर्डर कल पहुंच जाएगा।", "language": "hi-IN" }
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | Required | What to say |
language |
string | Optional | BCP-47 code, for example en-IN, hi-IN, ta-IN |
voice |
string | Optional | Voice ID. Omit to use the platform default voice |
Characters spoken with say are metered. Premium voices cost Rs 0.03 per character on top of call minutes. See Pricing and Limits.
play#
Plays an audio file.
{ "verb": "play", "url": "https://cdn.example.com/welcome.mp3", "loop": 2 }
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Required | Public URL of a WAV or MP3 file |
loop |
integer | Optional | How many times to play it. Default 1 |
play carries no TTS charge, so pre-recorded audio is the cheapest way to deliver a fixed message.
gather#
Speaks an optional prompt and collects keypad (DTMF) digits.
{
"verb": "gather",
"text": "Please enter your 4-digit PIN, then press hash.",
"num_digits": 4,
"finish_on_key": "#",
"timeout": 10,
"action": "https://example.com/edesy/pin"
}
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | Optional | Prompt spoken while waiting for input |
num_digits |
integer | Optional | Stop after this many digits |
finish_on_key |
string | Optional | Key that ends input, for example # |
timeout |
integer | Optional | Seconds to wait for input |
action |
string | Optional | URL that receives the digits |
If the caller enters nothing before timeout, the call moves on to the next instruction, so put a fallback say after the gather.
gather collects keypad digits. For spoken answers, use Conversation Relay (Edesy transcribes speech for you) or Media Streams (you run your own speech recognition).
record#
Records the caller, for example a voicemail. The recording is stored and listed by GET/v1/recordings.
{ "verb": "record", "max_length": 120, "play_beep": true, "finish_on_key": "#" }
| Field | Type | Required | Description |
|---|---|---|---|
max_length |
integer | Optional | Maximum length in seconds |
play_beep |
boolean | Optional | Play a beep before recording starts |
finish_on_key |
string | Optional | Key that stops the recording |
action |
string | Optional | URL notified when recording ends |
If a recording cannot be captured, the caller hears a short message instead and the call continues. To record a whole call, turn on recording for the number or application instead. See Recordings and Transcriptions.
dial#
Connects the caller to another phone number.
{ "verb": "dial", "number": "+9198XXXXXXXX", "caller_id": "+9180XXXXXXXX", "timeout": 30 }
| Field | Type | Required | Description |
|---|---|---|---|
number |
string | Required | E.164 number to dial |
caller_id |
string | Optional | Caller ID shown to the dialled party. Use a number in your workspace |
timeout |
integer | Optional | Seconds to ring, up to 600 |
action |
string | Optional | URL notified when the dialled leg ends |
To send inbound calls to a SIP endpoint rather than a phone number, use the SIP target in Inbound Routing.
stream#
Sends the call's audio to a WebSocket.
{ "verb": "stream", "url": "wss://bot.example.com/ws" }
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Required | wss:// endpoint |
For a two-way voice bot, prefer stream_url on POST/v1/calls or the media-stream inbound target. Those set up a bidirectional L16 stream with your choice of 8 kHz or 16 kHz and record call.stream.* events. See Media Streams.
redirect#
Continues the call with instructions from another URL.
{ "verb": "redirect", "url": "https://example.com/edesy/next-step" }
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Required | URL to fetch next |
enqueue#
{ "verb": "enqueue", "queue_name": "support" }
| Field | Type | Required | Description |
|---|---|---|---|
queue_name |
string | Required | Queue name |
enqueue is part of the VoiceML schema, but the answer-URL runtime does not execute it today and skips it. To queue callers for live agents, use the Edesy contact center at ccaas.edesy.in, which has queues, routing strategies, hold music and an agent desktop.
reject#
Declines an inbound call without answering it, so neither side is billed for talk time.
{ "verb": "reject", "status": 486, "reason": "Busy" }
| Field | Type | Required | Description |
|---|---|---|---|
status |
integer | Optional | SIP status from 400 to 699. Default 603 (decline). Use 486 for busy |
reason |
string | Optional | Reason phrase sent with the status |
reject only works before the call is answered, so make it the first instruction your inbound webhook returns.
hangup#
Ends the call.
{ "verb": "hangup" }
No fields.
pause#
Waits in silence.
{ "verb": "pause", "length": 2 }
| Field | Type | Required | Description |
|---|---|---|---|
length |
number | Optional | Seconds of silence. Default 1 |
Callback URLs (action, redirect)#
The answer URL is the documented, supported way to control a call: Edesy fetches it once and runs the whole document it returns. The action URLs on gather, record and dial, and the redirect target, are called directly by the media layer. Their request and response format is different from the answer URL and is not covered in these docs yet.
For a keypad menu with several steps, build the flow in IVR Studio and route the number to it, or run the conversation from your own server with Conversation Relay or Media Streams.
Examples#
Delivery notice with a recorded jingle
{
"instructions": [
{ "verb": "play", "url": "https://cdn.example.com/brand-jingle.mp3" },
{ "verb": "say", "text": "Your Acme parcel will be delivered today between 2 and 5 PM.", "language": "en-IN" },
{ "verb": "pause", "length": 1 },
{ "verb": "say", "text": "Thank you." },
{ "verb": "hangup" }
]
}
After-hours voicemail on an inbound number
{
"instructions": [
{ "verb": "say", "text": "Our office is closed. Please leave a message after the beep." },
{ "verb": "record", "max_length": 90, "play_beep": true },
{ "verb": "say", "text": "Thank you. We will call you back." },
{ "verb": "hangup" }
]
}
Forward an inbound call to a mobile
{
"instructions": [
{ "verb": "say", "text": "Connecting you to our team." },
{ "verb": "dial", "number": "+9198XXXXXXXX", "caller_id": "+9180XXXXXXXX", "timeout": 25 },
{ "verb": "say", "text": "Nobody is available right now. Please call again later." },
{ "verb": "hangup" }
]
}