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.

FieldTypeRequiredDescription
typestringYescall.request for a new call, call.input for a menu keypress
versionstringYesThe contract version, always "1"
callIdstringYesSent'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}
numberstringYesThe customer number that owns this call, in E.164 format. Verify the signature with this number's callback secret
timestampstringYesWhen the question was asked, ISO 8601

Questions

call.request

A new call, asking your backend what should happen to it.

FieldTypeRequiredDescription
directionstringYesoutbound 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
fromobjectYesWho placed the call, an address
toobjectYesWho the call was for, an address
testbooleanNotrue 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.

FieldTypeRequiredDescription
digitsstringYesThe digits collected. An empty string when the menu timed out with nothing pressed
cookiesobjectNoEvery 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.

kindOther fieldsDescription
useridentityOne of your app users, named by the identity you minted a voice token for
numbernumberA phone number in E.164 format
anonymousnoneA caller who withheld their number
conferencenameA conference room, named within your account

Answers

Your response body is one JSON object:

{ "instructions": [], "action": {} }
FieldTypeRequiredDescription
instructionsarrayNoSteps to run in order before the action, for example a spoken greeting. Instructions never decide where the call goes
actionobjectYesExactly 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

actionWhat it does
connectToUserRings one of your app users
connectToNumberDials a phone number
connectToConferencePuts the call into a conference room
playMenuPlays a menu and collects keypresses
parkHolds the call
hangupEnds the call
rejectTurns the call away without connecting it

connectToUser

Rings an app user of your account.

FieldTypeRequiredDescription
identitystringYesThe app user to ring. Letters, digits, - and _, up to 200 characters
recordbooleanNoAccepted 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.

FieldTypeRequiredDescription
numberstringYesThe phone number to dial, in E.164 format
callerIdstringNoThe number shown to the callee. Must be a number you own through Sent. The call's owning number is used when omitted
dialTimeoutSecondsintegerNoHow long to ring before giving up. Only phone legs have a dial timeout; omitted leaves the default in place
recordbooleanNotrue 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.

FieldTypeRequiredDescription
namestringYesThe room name. Letters, digits, - and _, up to 27 characters. The same name on two calls puts them in the same room
recordbooleanNotrue 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.

FieldTypeRequiredDescription
promptobjectYesWhat to say, as { "text": "..." }. Text only, and the text cannot contain ]
maxDigitsintegerNoHow many digits to collect before asking you
timeoutSecondsintegerNoHow long to wait between digits before treating the input as finished
repeatsintegerNoHow 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.

FieldTypeRequiredDescription
introPromptobjectNoPlayed once when the call is first held, as { "text": "..." }
holdPromptobjectNoRepeated until the call is connected or the hold expires, as { "text": "..." }
maxDurationSecondsintegerNoHow 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.

FieldTypeRequiredDescription
reasonstringNobusy 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.

instructionFieldsDescription
saytext (required), localeSpeaks the text to the caller. locale is the voice and language, as an ISO 639 locale such as en-US
playurl (required)Plays an audio file from an absolute http or https URL
sendDigitsdigits (required)Sends DTMF tones down the line to dial an extension or enter a PIN code. w is a half-second pause
startRecordingnoneStarts recording the call. Left out when call recording is not available, and when the answer's action is connectToUser
stopRecordingnoneStops a recording started earlier in the call
answernoneAnswers the call before running the rest of the answer
setCookiekey (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.
  • instructions must be an array. Each item must be an object with an instruction name.
  • There must be exactly one action key. None is missing_action. More than one, or an array of two or more, is multiple_actions. An array of one is invalid_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 true or false. Absent means false.
  • park.maxDurationSeconds over 600 is park_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. A url key 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.url must be an absolute http or https URL.

Account rules

After parsing, a connectToNumber answer is checked against your account. The other actions have no account rules.

  • number must 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, otherwise invalid_field. A valid number this account does not own through Sent is caller_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: number is written in E.164, and a missing callerId is filled in with the call's owning number. The test endpoint returns the normalised answer under answer.
  • 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

reasonWhat was wrong
malformed_jsonThe body was empty, not a JSON object, or not valid JSON
missing_actionNo action key, or no action.action name
multiple_actionsMore than one action key, or an array of two or more actions
unknown_actionThe action name is not one of the seven actions
unknown_instructionAn instruction has no name, or a name that is not one of the seven instructions
park_duration_exceededpark.maxDurationSeconds is over 600
invalid_fieldA field has the wrong type, is empty when required, or is out of range
caller_id_not_ownedconnectToNumber.callerId is not a number this account owns through Sent
destination_blockedconnectToNumber.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.

On this page