More sandboxes
On this page
Voice & OTP calls
Test phone-call flows (voice one-time codes, IVR menus, call notifications) without ringing a phone. The voice sandbox speaks the Twilio Voice API: your app places calls with the official Twilio SDK pointed at Testlie, Testlie plays the person who picks up, runs your TwiML and records everything that would have been said. Tests then wait for the call and read the spoken code.
Calls use the same fake Twilio account as the SMS sandbox: the SMS inbox's Account SID and auth token, and the same base URL. Open Voice calls in the workspace to see calls, their transcript and a timeline of every TwiML request, Gather input and status callback.
Place a call
Replace https://api.twilio.com with https://testlie.com/sms and authenticate with HTTP Basic AccountSid:AuthToken. Parameters are form-encoded like Twilio's:
curl -u "$ACCOUNT_SID:$AUTH_TOKEN" \
-X POST "https://testlie.com/sms/2010-04-01/Accounts/$ACCOUNT_SID/Calls.json" \
--data-urlencode 'To=+15551234567' \
--data-urlencode 'From=+15005550006' \
--data-urlencode 'Url=https://your-app.example/twiml/otp' \
--data-urlencode 'StatusCallback=https://your-app.example/twilio/status' \
--data-urlencode 'StatusCallbackEvent=initiated ringing answered completed'With the Node SDK:
const client = require("twilio")(ACCOUNT_SID, AUTH_TOKEN);
client.api.baseUrl = "https://testlie.com/sms";
await client.calls.create({ to: "+15551234567", from: "+15005550006", url: "https://your-app.example/twiml/otp" });The other SDKs work the same way as for SMS: see the snippets on the SMS page and use calls.create instead of messages.create.
| Parameter | Notes |
|---|---|
To, From |
E.164 numbers (To may also be client: or sip:) |
Url, Method |
Where to fetch TwiML when the call is answered; POST (default) or GET |
Twiml |
Inline TwiML instead of Url, up to 4,000 characters |
FallbackUrl, FallbackMethod |
Fetched with ErrorCode and ErrorUrl when Url fails |
StatusCallback, StatusCallbackMethod, StatusCallbackEvent |
Events initiated, ringing, answered, completed (default: completed only) |
MachineDetection |
Enable or DetectMessageEnd: adds AnsweredBy |
Timeout, TimeLimit |
Ring timeout and maximum call length in seconds |
ApplicationSid, SendDigits, Record on the call and other parameters are not supported or ignored.
| Method and path | |
|---|---|
POST /sms/2010-04-01/Accounts/{AccountSid}/Calls.json |
Place a call. 201 with Twilio's Call resource (status: "queued") |
GET /sms/2010-04-01/Accounts/{AccountSid}/Calls.json |
List, newest first: To, From, Status, StartTime (<, >), PageSize, Page |
GET /sms/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json |
Fetch one call |
POST /sms/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json |
Modify a live call: Status=canceled (queued or ringing), Status=completed (any live state), or Url/Method or Twiml to redirect it |
DELETE /sms/2010-04-01/Accounts/{AccountSid}/Calls/{Sid}.json |
Delete a finished call |
Errors use Twilio's JSON (code, message, more_info, status):
| Code | HTTP | When |
|---|---|---|
| 20003 | 401 | Missing or wrong credentials |
| 20404 | 404 | Unknown call SID or path |
| 20429 | 429 | Over the monthly quota, or more than 50 requests per second |
| 21201 / 21211 | 400 | Missing / invalid To |
| 21213 / 21212 | 400 | Missing / invalid From |
| 21205 | 400 | No Url or Twiml, or an invalid URL |
| 12100 | 400 | Inline Twiml is not valid TwiML |
| 21609 | 400 | Invalid StatusCallback URL |
| 21220 | 400 | Modifying (or deleting) a call in the wrong state |
| 20001 | 400 | Another invalid parameter (method, event name, machine detection) |
What happens during a call
- The call is
queued, thenringing(about a second). Status callbacks forinitiatedandringinggo out if you asked for them. - The simulated callee reacts (see below). A busy, unanswered or failed line ends the call with that status; no TwiML is fetched.
- When answered, the call is
in-progress, theansweredcallback goes out, and Testlie requests your TwiML with Twilio's parameters (CallSid,AccountSid,From,To,CallStatus,Direction,ApiVersion,Called,Caller, andAnsweredBywith machine detection), signed withX-Twilio-Signature, sotwilio.validateRequestand Twilio's webhook middleware accept it. - The verbs run in order. When the document ends, or on
<Hangup>, the call iscompleted, thecompletedcallback reportsCallDuration, and team webhooks getcall.completed.
Nothing waits for real audio: durations are simulated (speech at about 150 words a minute, <Pause> lengths, Gather timeouts). A <Pause> does wait a tenth of its length in real time, so a test can modify the live call.
TwiML
| Verb | Sandbox behaviour |
|---|---|
<Say> |
Text (with SSML such as <say-as>, <sub alias>, <break>) goes into the transcript with voice, language and loop (loop="0" counts as 10) |
<Play> |
The URL, or digits, goes into the transcript; loop honoured |
<Pause> |
Adds length seconds |
<Gather> |
Plays nested <Say>, <Play>, <Pause>, then takes the callee's next scripted input. Honours input (dtmf, speech), numDigits, finishOnKey, timeout, action, method and actionOnEmptyResult. Input is posted as Digits (and FinishedOnKey) or SpeechResult and Confidence, and the call continues with the TwiML you return. Without input it falls through to the next verb. Without action it posts to the current document's URL, like Twilio |
<Redirect> |
Fetches the URL (relative URLs resolve against the current document) |
<Hangup> |
Ends the call |
<Reject> |
Ends the call; as the first verb the call is busy |
<Dial> |
Captured, never connected. Each <Number> (or text, <Client>, <Sip>) gets its outcome from the simulated callee rules; action receives DialCallStatus, DialCallSid and DialCallDuration |
<Record> |
Simulated: a 5-second recording (or maxLength), posted to action as RecordingUrl, RecordingDuration, RecordingSid and Digits. The recording URL is a placeholder; there is no audio |
<Sms> |
Captured in the account's SMS inbox like a Messages API call (to defaults to the called number) |
Other Twilio verbs (<Connect>, <Enqueue>, <Pay>, ...) are recorded as skipped. TwiML can also be text/plain (said) or an audio/* URL (played).
Like Twilio, invalid TwiML ends the call with "We are sorry, an application error has occurred": a parse failure is error 12100, an unknown verb or a verb nested where it is not allowed (only <Say>, <Play> and <Pause> inside <Gather>; only nouns inside <Dial>) is 12200, an HTTP error 11200, an unreachable app 11205, a document over 64 KB 11750, and an unexpected content type 12300. The error shows on the timeline and in the call's errorCode.
Loop guards. One call makes at most 25 TwiML requests (redirects, actions) and runs at most 250 verbs; past that it ends with the application error and a "loop guard" message. TimeLimit caps the simulated length.
Simulated callees
Per account, rules decide how the number you call behaves. Open Voice calls → Simulated callees, or PUT /api/sms-inboxes/<id>/voice:
{ "rules": [
{ "pattern": "+1555000*", "behavior": "busy" },
{ "pattern": "+1555001*", "behavior": "no-answer" },
{ "pattern": "+1555002*", "behavior": "machine" },
{ "pattern": "+15551234567", "behavior": "answer", "inputs": ["1", "4719#"] }
] }| Behaviour | Result |
|---|---|
answer |
A person answers (AnsweredBy=human with machine detection) and gives inputs to successive <Gather>s |
machine |
An answering machine: AnsweredBy=machine_start (machine_end_beep with DetectMessageEnd); it never presses keys |
busy, no-answer, failed |
The call ends with that status |
Inputs are DTMF digits (0-9, *, #, and w for a pause; "1234#" types 1234 then pound) or speech:<words>. "1" then "4719#" means: press 1 at the menu, then enter 4719 and pound. A numDigits Gather takes only that many digits; a speech-only Gather ignores digits and vice versa. Unmatched numbers answer and press nothing. Patterns use * and ? like SMS failure rules; the first match wins.
Read the code in a test
Wait for the finished call with a team API key. By default only finished calls match, so the transcript is complete:
curl -f -H "Authorization: Bearer $TESTLIE_API_KEY" \
"https://testlie.com/api/sms-inboxes/$SMS_INBOX_ID/calls/wait?to=%2B15551234567&since=$SINCE&timeout=30"const since = new Date().toISOString();
await app.requestVoiceCode("+15551234567");
const res = await fetch(`${TESTLIE}/api/sms-inboxes/${id}/calls/wait?to=%2B15551234567&since=${since}`,
{ headers: { Authorization: `Bearer ${TESTLIE_KEY}` } });
const { call } = await res.json();
expect(call.status).toBe("completed");
const code = call.extracted.codes[0]; // "4719" from "Your code is 4. 7. 1. 9."The response has call.spokenText, the ordered transcript, the steps timeline and extracted.codes. Codes are found whether they are spoken digit by digit ("4. 7. 1. 9.", "4, 7, 1, 9", "four seven one nine") or grouped ("471 925"), with codes next to words like "code" ranked first.
| Method and path | |
|---|---|
GET /api/sms-inboxes/:id/calls |
List calls: to, status, q (spoken text contains), page, limit |
DELETE /api/sms-inboxes/:id/calls |
Delete every finished call |
GET /api/sms-inboxes/:id/calls/wait |
Long-poll: to, status (final by default, a status, or any), contains, since, timeout (1-120 s). 200 with { call } or 408 |
GET, PUT /api/sms-inboxes/:id/voice |
The Calls URL and the simulated callee rules (PUT for admins) |
GET, PATCH, DELETE /api/calls/:sid |
One call with timeline and transcript; mark read; delete |
GET /api/calls/:sid/extract |
{ sid, status, spokenText, codes, links } |
Webhooks
Team-wide webhooks subscribed to call.completed get a signed JSON POST when a call ends (any final status):
{ "event": "call.completed",
"smsInbox": { "id": "…", "name": "App SMS" },
"call": { "sid": "CA…", "to": "+15551234567", "from": "+15005550006", "status": "completed", "answeredBy": null,
"duration": 14, "spokenText": "…", "codes": ["471925"], "errorCode": null, "createdAt": "…", "endTime": "…", "url": "…" } }Limits and retention
Calls count against the plan's SMS per month limit (metered separately from texts), each account keeps its newest calls up to the plan's per-inbox limit, and calls are deleted with the team's retention like captured SMS. See Plans & limits. Outgoing requests to your app are SSRF-guarded like webhooks: private addresses are refused.
Calls ring for about a second, and <Pause> is shortened (a tenth of a second per second, at most 30 seconds of real waiting per call) so tests stay fast.