Voice Callback Contract
When a call arrives on one of your voice numbers, or a caller presses a key on a menu, Sent POSTs a signed question to that number's callback URL and follows your answer. This page is the reference for both sides of that exchange: the question Sent sends and the answer your backend returns. For the flow around it, including signature verification, deadlines and retries, see Answering Calls.
The callback contract uses camelCase (callId, dialTimeoutSeconds). The REST API and the webhooks use snake_case (call_id, duration_seconds). Your backend reads one casing on the question and writes the same casing on the answer; the other two surfaces keep theirs.
How the contract is used
Every exchange is one HTTP request from Sent to you. The body is a question, sent as application/json. Your response body is an answer: optional instructions to run in order, then exactly one action that decides where the call goes. A menu chains by answering a call.input question with another playMenu or a connect action. The same contract drives the test question from POST /v3/channels/voice/{number}/test, so a backend that passes the test answers real calls the same way. Worked examples are in Voice Recipes.
The envelope
Every question carries these fields. Keys are camelCase and null values are left out.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | call.request for a new call, call.input for a menu keypress |
version | string | Yes | The contract version, always "1" |
callId | string | Yes | Sent's call id, call_ followed by a GUID. The same id on every question for the call, on every call webhook, and on GET /v3/calls/{id} |
number | string | Yes | The customer number that owns this call, in E.164 format. Verify the signature with this number's callback secret |
timestamp | string | Yes | When the question was asked, ISO 8601 |
Questions
call.request
A new call, asking your backend what should happen to it.
| Field | Type | Required | Description |
|---|---|---|---|
direction | string | Yes | outbound when an app user placed the call, inbound when someone dialed one of your numbers. A call is outbound exactly when from is an app user |
from | object | Yes | Who placed the call, an address |
to | object | Yes | Who the call was for, an address |
test | boolean | No | true only on the question POST /v3/channels/voice/{number}/test sends. Real calls never carry the field, so treat its absence as "this call is live" |
An app user calling a phone number:
{
"type": "call.request",
"version": "1",
"callId": "call_9f2ab000-0000-4000-8000-000000000001",
"number": "+38349111222",
"timestamp": "2026-07-22T12:00:00+00:00",
"direction": "outbound",
"from": { "kind": "user", "identity": "agent-42" },
"to": { "kind": "number", "number": "+14155551234" }
}Someone dialing one of your numbers:
{
"type": "call.request",
"version": "1",
"callId": "call_9f2ab000-0000-4000-8000-000000000001",
"number": "+38349111222",
"timestamp": "2026-07-22T12:05:00+00:00",
"direction": "inbound",
"from": { "kind": "number", "number": "+38344555666" },
"to": { "kind": "number", "number": "+38349111222" }
}A caller who withheld their number:
{
"type": "call.request",
"version": "1",
"callId": "call_9f2ab000-0000-4000-8000-000000000001",
"number": "+38349111222",
"timestamp": "2026-07-22T12:06:00+00:00",
"direction": "inbound",
"from": { "kind": "anonymous" },
"to": { "kind": "number", "number": "+38349111222" }
}call.input
What the caller pressed on a menu, asking what to do next. Menus chain by answering this with another playMenu or a connect action.
| Field | Type | Required | Description |
|---|---|---|---|
digits | string | Yes | The digits collected. An empty string when the menu timed out with nothing pressed |
cookies | object | No | Every value a setCookie instruction stored earlier in this call, echoed back so your backend can stay stateless. Absent when the call has no cookies |
A later setCookie with the same key replaces the earlier value, and every later question carries the whole set. Cookies live for the life of the call.
{
"type": "call.input",
"version": "1",
"callId": "call_9f2ab000-0000-4000-8000-000000000001",
"number": "+38349111222",
"timestamp": "2026-07-22T12:00:07+00:00",
"digits": "1"
}With cookies stored by an earlier answer:
{
"type": "call.input",
"version": "1",
"callId": "call_9f2ab000-0000-4000-8000-000000000001",
"number": "+38349111222",
"timestamp": "2026-07-22T12:00:07+00:00",
"digits": "1",
"cookies": { "step": "greeted", "department": "sales" }
}Addresses
from and to are addresses, told apart by kind. Identities and room names are bare: the account prefix never reaches your backend.
kind | Other fields | Description |
|---|---|---|
user | identity | One of your app users, named by the identity you minted a voice token for |
number | number | A phone number in E.164 format |
anonymous | none | A caller who withheld their number |
conference | name | A conference room, named within your account |
Answers
Your response body is one JSON object:
{ "instructions": [], "action": {} }| Field | Type | Required | Description |
|---|---|---|---|
instructions | array | No | Steps to run in order before the action, for example a spoken greeting. Instructions never decide where the call goes |
action | object | Yes | Exactly one action, told apart by its action name, deciding where the call goes |
Answer with a 2xx status. A 4xx status tells Sent you refuse the call. On a call.request the call ends FAILED with reason rejected; on a call.input the call was already answered, so it is hung up and ends COMPLETED. For what happens on a timeout, a 5xx or an invalid answer, see Answering Calls.
Actions
action | What it does |
|---|---|
connectToUser | Rings one of your app users |
connectToNumber | Dials a phone number |
connectToConference | Puts the call into a conference room |
playMenu | Plays a menu and collects keypresses |
park | Holds the call |
hangup | Ends the call |
reject | Turns the call away without connecting it |
connectToUser
Rings an app user of your account.
| Field | Type | Required | Description |
|---|---|---|---|
identity | string | Yes | The app user to ring. Letters, digits, - and _, up to 200 characters |
record | boolean | No | Accepted but never honoured: a call connected to an app user cannot be recorded. The call connects without the recording |
{ "action": { "action": "connectToUser", "identity": "ben", "record": false } }connectToNumber
Dials a phone number. The leg runs for at most what your balance affords at the destination's per-minute rate, but never less than one minute, and never longer than four hours. A leg to an app user or a room has no such limit, because it costs nothing.
| Field | Type | Required | Description |
|---|---|---|---|
number | string | Yes | The phone number to dial, in E.164 format |
callerId | string | No | The number shown to the callee. Must be a number you own through Sent. The call's owning number is used when omitted |
dialTimeoutSeconds | integer | No | How long to ring before giving up. Only phone legs have a dial timeout; omitted leaves the default in place |
record | boolean | No | true records the call from the moment the legs join. See Calls, Recordings, and Participants |
{
"action": {
"action": "connectToNumber",
"number": "+38349123456",
"callerId": "+14155551234",
"dialTimeoutSeconds": 30,
"record": false
}
}connectToConference
Puts the call into a conference room belonging to your account. A call in a room can take participants through POST /v3/calls/{id}/participants.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The room name. Letters, digits, - and _, up to 27 characters. The same name on two calls puts them in the same room |
record | boolean | No | true records the call once it joins the room |
{ "action": { "action": "connectToConference", "name": "daily-standup", "record": false } }playMenu
Plays a menu and collects keypresses. The digits come back as a call.input question, which is how menus chain.
| Field | Type | Required | Description |
|---|---|---|---|
prompt | object | Yes | What to say, as { "text": "..." }. Text only, and the text cannot contain ] |
maxDigits | integer | No | How many digits to collect before asking you |
timeoutSeconds | integer | No | How long to wait between digits before treating the input as finished |
repeats | integer | No | How many times the prompt repeats when nothing valid is pressed |
{
"action": {
"action": "playMenu",
"prompt": { "text": "Press 1 for sales, 2 for support" },
"maxDigits": 1,
"timeoutSeconds": 5,
"repeats": 2
}
}Collecting several digits, with a cookie that tells the next question which step the caller is on:
{
"instructions": [
{ "instruction": "setCookie", "key": "step", "value": "account-number" }
],
"action": {
"action": "playMenu",
"prompt": { "text": "Enter your four digit account number" },
"maxDigits": 4,
"timeoutSeconds": 8
}
}park
Holds the call, playing the hold prompt until the hold runs out. One hold lasts at most 600 seconds, and the call ends when it is reached, so say how long the caller may wait in the prompts.
| Field | Type | Required | Description |
|---|---|---|---|
introPrompt | object | No | Played once when the call is first held, as { "text": "..." } |
holdPrompt | object | No | Repeated until the call is connected or the hold expires, as { "text": "..." } |
maxDurationSeconds | integer | No | How long this hold lasts, at most 600 |
{
"action": {
"action": "park",
"introPrompt": { "text": "Please hold" },
"holdPrompt": { "text": "Still with you" },
"maxDurationSeconds": 600
}
}hangup
Ends the call. No fields.
{ "action": { "action": "hangup" } }reject
Turns the call away without connecting it. On a call.request the call ends FAILED with reason rejected. On a call.input the call was already answered, so it is hung up and ends COMPLETED.
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | No | busy or declined. Defaults to declined |
{ "action": { "action": "reject", "reason": "busy" } }Instructions
Instructions are optional, run in the order given, and never decide where the call goes. Each is told apart by its instruction name.
instruction | Fields | Description |
|---|---|---|
say | text (required), locale | Speaks the text to the caller. locale is the voice and language, as an ISO 639 locale such as en-US |
play | url (required) | Plays an audio file from an absolute http or https URL |
sendDigits | digits (required) | Sends DTMF tones down the line to dial an extension or enter a PIN code. w is a half-second pause |
startRecording | none | Starts recording the call. Left out when call recording is not available, and when the answer's action is connectToUser |
stopRecording | none | Stops a recording started earlier in the call |
answer | none | Answers the call before running the rest of the answer |
setCookie | key (required), value (required) | Stores a value against the call, echoed back in cookies on every later question for it |
Every instruction in one answer:
{
"instructions": [
{ "instruction": "say", "text": "Welcome to Acme", "locale": "en-US" },
{ "instruction": "play", "url": "https://cdn.acme.com/greeting.mp3" },
{ "instruction": "sendDigits", "digits": "1w2" },
{ "instruction": "startRecording" },
{ "instruction": "stopRecording" },
{ "instruction": "answer" },
{ "instruction": "setCookie", "key": "step", "value": "greeted" }
],
"action": { "action": "hangup" }
}Parsing rules
Sent reads your answer with these rules. An answer that breaks one is invalid, and the reason is reported as described under Answer error reasons.
- Fields the contract does not define are ignored. An unknown action or instruction name is an error.
- An empty body, a body that is not a JSON object, or invalid JSON is
malformed_json. instructionsmust be an array. Each item must be an object with aninstructionname.- There must be exactly one
actionkey. None ismissing_action. More than one, or an array of two or more, ismultiple_actions. An array of one isinvalid_field: "'action' must be an object, not a list." - Strings must be strings. An empty string counts as absent, so a required string that is empty fails with
'{property}' is required and must be a non-empty string. - Integers (
dialTimeoutSeconds,maxDigits,timeoutSeconds,repeats,maxDurationSeconds) must be whole numbers from 1 up. - Booleans must be
trueorfalse. Absent meansfalse. park.maxDurationSecondsover 600 ispark_duration_exceeded: "A single hold can last at most 600 seconds. Answer the next question with another park to keep holding."- Prompts are
{ "text": "..." }only. Aurlkey is refused: "Prompts are text only. Use a 'play' instruction to play audio." The text cannot contain]: "Prompt text cannot contain ']'. Use a 'say' instruction for text that needs one." - An identity is up to 200 characters of letters, digits,
-and_. A room name is up to 27 characters of the same set. play.urlmust be an absolutehttporhttpsURL.
Account rules
After parsing, a connectToNumber answer is checked against your account. The other actions have no account rules.
numbermust be in E.164 format.- A destination in a blocked country is
destination_blocked: "Calls to this destination are not allowed." callerId, when present, must be in E.164 format, otherwiseinvalid_field. A valid number this account does not own through Sent iscaller_id_not_owned: "'callerId' must be a phone number this account owns through Sent." Ownership is read from your number inventory; the caller id does not have to be voice-enabled itself.- The answer is normalised:
numberis written in E.164, and a missingcallerIdis filled in with the call's owning number. The test endpoint returns the normalised answer underanswer. - Connecting to a phone on a call that had no phone destination needs a balance greater than zero. A call refused for balance ends with reason
insufficient_balance.
Answer error reasons
reason | What was wrong |
|---|---|
malformed_json | The body was empty, not a JSON object, or not valid JSON |
missing_action | No action key, or no action.action name |
multiple_actions | More than one action key, or an array of two or more actions |
unknown_action | The action name is not one of the seven actions |
unknown_instruction | An instruction has no name, or a name that is not one of the seven instructions |
park_duration_exceeded | park.maxDurationSeconds is over 600 |
invalid_field | A field has the wrong type, is empty when required, or is out of range |
caller_id_not_owned | connectToNumber.callerId is not a number this account owns through Sent |
destination_blocked | connectToNumber.number is in a country calls are not allowed to |
The test endpoint reports these in error.reason, with error.path naming the field at fault (such as action.action) and error.message saying what to fix. On a live call an invalid answer is asked for once more. If the second attempt does not produce a usable answer either, a call.request fails with the reason the last attempt produced, invalid_answer for an unusable body, and a call.input is hung up and ends COMPLETED. The call failure reasons are listed in the Error Catalog.
Versioning
Every question carries version: "1", the only version today. Changes within a version are additive: a question may gain fields, so ignore fields you do not know and never rely on key order. A field is never removed or given a new meaning without a new version, and the version is sent on every question so your backend can check it.
Add an RCS agent POST
Asks for an RCS agent and records the order form the carriers vet it against. Always `202`: an agent is approved by the carriers, not by this call, and nothing here can make that happen sooner. ## What it asks for The RCS Agent Order Form — the same one our team fills in on your behalf today, so nothing has to be collected from you twice. **All fields are required** and must be submitted together so the carrier review has everything it needs. The only exception is `opt_in_screenshot_url`. **Agent identity:** `display_name`, `description`, `agent_use_case` (must be one of the accepted use cases; the error names the set), `brand_color` (six-digit hex, `#RRGGBB`). **Brand identity:** `brand_name` — the brand this agent presents. Not taken from `compliance.brand`: a business may present a different brand on RCS than the one its 10DLC registration is filed against, and the carriers vet this one. `privacy_policy_url`, `terms_and_conditions_url`, `website_url`, `logo_url`, and `banner_url` must be absolute `http(s)` URLs that the carriers can fetch without authentication. The form specifies 224×224 px and 50 KB for the logo and 1440×448 px and 200 KB for the banner; these cannot be checked without the image bytes. **Contact details:** `brand_phone_number`, `customer_support_phone_number`, `brand_email`, `customer_support_email`, `contact_name_and_title`. **Company:** `company_ein`, `entity_type`, `official_address` (street, city, state, postal_code, country), `brief_company_description`. **Consent and messages:** `opt_in_process_description`, `start_message`, `help_message`, `stop_message`, `sample_messages` (at least one example message). **Optional:** `opt_in_screenshot_url` (validated as an http(s) URL if supplied). Anything supplied is checked against accepted values rather than passed through: a value the provider rejects would otherwise surface days later, to somebody who cannot fix it without asking you. **A customer has one agent.** Asking again while you have one is `409`. ## What comes back, and what does not Returns **the agent** — the form as stored, plus `id` and `status`. Not your other channels, which this call cannot have changed. `status` is `PROVISIONING` until the agent is handed to the carriers and approved. **`GET /v3/channels` reports `rcs: null` until then** — a channel row is what routing reads to decide you are sendable, and writing one for an unapproved agent would claim a channel that cannot carry traffic. Send `x-profile-id` with an organization key to ask for one of your profiles' agents.
Turn phone calls on for a number POST
Adds voice to one of the numbers you hold, or gives you a new one. Send `number` for a number that is already yours (see `GET /v3/channels`); leave it out to be given a new US number, optionally in a particular `area_code`. Sending both is refused. Nothing registers, so the number can carry calls as soon as this returns. What happens on a call is decided by your `callback_url`: when a call arrives on the number, or a caller presses a key on a menu, Sent POSTs a signed question there and follows the answer. The response carries the `callback_secret` the questions are signed with, the one time it is shown without rotating; verify a question the way you verify a webhook. `POST /v3/channels/voice/{number}/test` sends a test question and reports the verdict. Your first voice number becomes the line app-originated calls are placed from when a voice token names no number; send `default_for_app_calls: true` to give that role to another number. A number you turned off earlier is turned back on, and the same number with a different `callback_url` has its URL replaced and keeps its secret. Read the number's settings with `GET /v3/channels/voice` and change them with `PATCH /v3/channels/voice/{number}`. With `sandbox: true` the request is validated and a simulated number reported with `202`; nothing is written and no number is bought.