Programmable Voice
Place phone calls from your own platform and control them from your server. Statxt dials the number and, when it answers, asks your server what to do: speak, play audio, collect digits, connect another number, or record. Every call uses the same API and the same instructions, whichever of your numbers places it.
Quickstart
- Create an API key with
calls:write(tick Voice calls in Settings → Developers), and pick a voice number fromGET /v1/phone-numbers. - Get your voice signing secret with
POST /v1/voice/signing-secret/rotate(an organization-scoped key). It is shown once. - Host an answer URL that returns call instructions, and place the call:
curl https://app.statxt.com/api/v1/calls \
-H "Authorization: Bearer intx_live_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"from_phone_number_id": "<id from GET /v1/phone-numbers>",
"answer_url": "https://example.com/voice/answer",
"status_callback": "https://example.com/voice/status",
"client_reference": "order_8812"
}'// Express
app.post("/voice/answer", verifyStatxt, (req, res) => {
res.type("text/xml").send(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Gather numDigits="1" action="/voice/menu">
<Say>Your order ships today. Press 1 to talk to us, or 2 to hang up.</Say>
</Gather>
<Say>We didn't get that. Goodbye.</Say>
</Response>`)
})
app.post("/voice/menu", verifyStatxt, (req, res) => {
const xml = req.body.Digits === "1"
? "<Response><Dial>+14155550100</Dial></Response>"
: "<Response><Hangup/></Response>"
res.type("text/xml").send(xml)
})To try it without a server, send the instructions inline instead of an answer_url:
{
"to": "+14155551234",
"from_phone_number_id": "...",
"instructions": "<Response><Say>Hello from Statxt.</Say></Response>"
}Call instructions
XML with a <Response> root, run top to bottom. When the instructions end, the call hangs up. Relative URLs resolve against the URL the instructions came from. Unknown verbs and attributes are errors, never ignored. Documents are at most 64 KB and 100 verbs. If a number can't run a verb or option, the request fails with capability_not_supported.
| Verb | Attributes | What it does |
|---|---|---|
| <Say> | voice (woman | man), language, loop (1–10) | Speaks text, up to 4,000 characters. |
| <Play> | loop (0–10; 0 repeats until the call ends) | Plays an https mp3 or wav file, up to 10 MB. |
| <Pause> | length (1–60 seconds) | Waits silently. |
| <Gather> | action, method, timeout, finishOnKey (one key), numDigits | Collects keypad digits. Nest <Say>, <Play> and <Pause> as the prompt. With digits, requests action; without, continues with the next verb. |
| <Dial> | action, method, timeout, callerId, timeLimit | Calls one number (text or <Number>) and connects it. When that leg ends, requests action or continues. |
| <Record> | action, method, maxLength, timeout, finishOnKey, playBeep | Records the caller, then requests action with the recording. |
| <Redirect> | method | Runs the instructions at another URL. |
| <Hangup> | Ends the call. |
Requests to your server
Statxt requests your answer_url, each verb's action and <Redirect> URLs with form-encoded fields (in the query string for GET):
| CallSid | The call's id (the same id as GET /v1/calls/{id}). |
| AccountSid | Your organization's id. |
| From, To | E.164 numbers. |
| Direction | outbound-api, or outbound-dial for a leg placed by <Dial>. |
| CallStatus | queued, initiated, ringing, in-progress, completed, busy, no-answer, failed or canceled. |
| ApiVersion | 2026-10-01. |
| ClientReference | The client_reference you placed the call with, when set. |
| Digits, FinishedOnKey | After <Gather>. |
| DialCallSid, DialCallStatus, DialCallDuration | After <Dial>. ErrorCode explains a dial that couldn't be placed. |
| RecordingSid, RecordingUrl, RecordingDuration, Digits | After <Record>. Fetch RecordingUrl with your API key. |
| ErrorCode, ErrorUrl | On a fallback_url request. |
Verify requests
Every request carries X-STATXT-Timestamp and X-STATXT-Signature: v1=hex HMAC-SHA256 of {timestamp}.{full URL}.{raw body}, signed with your voice signing secret. For 24 hours after a rotation the header carries one signature per secret, comma-separated.
import crypto from "node:crypto"
// Express: app.use(express.urlencoded({ extended: false, verify: (req, _res, buf) => { req.rawBody = buf } }))
export function verifyStatxt(req, res, next) {
const timestamp = req.get("X-STATXT-Timestamp") ?? ""
const header = req.get("X-STATXT-Signature") ?? ""
const url = `https://${req.get("host")}${req.originalUrl}` // the exact URL Statxt requested
const body = req.method === "GET" ? "" : (req.rawBody?.toString("utf8") ?? "")
const expected = "v1=" + crypto
.createHmac("sha256", process.env.STATXT_VOICE_SECRET)
.update(`${timestamp}.${url}.${body}`)
.digest("hex")
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300
const match = header.split(",").some((sig) =>
sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
return fresh && match ? next() : res.status(403).end()
}Failures and limits
- URLs must be public
httpson port 443 or 8443, using a host name (url_not_allowed). - Your server has 8 seconds to answer with a 2xx
text/xmlresponse. Redirects aren't followed. - If a request fails, Statxt requests
fallback_urlwithErrorCodeandErrorUrl. If that fails too, the call hangs up without a message, and the call'serror_codesays why. - A call fetches at most 50 documents and follows at most 10 redirects in a row.
- By default an organization places at most 1 programmable call per second and has 10 in progress at once (
429 rate_limited). Ask us to raise these. - Calls follow Do Not Call and are billed per minute. A
<Dial>leg is its own billed call.
Call progress
status_callback receives signed, form-encoded requests for the events in status_callback_events (initiated, answered, completed; default completed), with CallStatus, CallDuration, SequenceNumber and Timestamp. The call.* webhook events fire as for any call. Recordings are listed at GET /v1/calls/{id}/recordings.
Coming from another voice platform
Statxt call instructions use the widely used <Response> XML verbs and webhook field names, so most existing call flows run by pointing answer_url at them. Differences: only the verbs and attributes above are supported, ApiVersion is 2026-10-01, requests are signed with X-STATXT-Signature, and RecordingUrl needs your API key.
