Voice Recipes

These are the routing patterns the Sent voice example app implements, written down as the answers its callback gives. Each one is an answer to a question Sent asks. It uses the actions and instructions of the callback contract. The JSON examples are the contract's own fixtures where one exists. Every pattern here has been run end to end with the example app.

Prerequisites

  • A callback that verifies each question and answers within 2.5 seconds. See Answering Calls.
  • For the patterns that ring an app user, agents registered with @sentdm/voice. See Calls From Your App.

Ring an App User, Fall Back to a Phone

The example app's default routing decides from to and from who is online. Who is online is your backend's own knowledge, for example a heartbeat each registered browser sends you; Sent does not store presence.

When the call is for an app user and that user is online, ring them:

{ "action": { "action": "connectToUser", "identity": "ben", "record": false } }

When the user is offline, dial a fallback phone instead, presenting one of your numbers as the caller id:

{
  "action": {
    "action": "connectToNumber",
    "number": "+38349123456",
    "callerId": "+14155551234",
    "dialTimeoutSeconds": 30,
    "record": false
  }
}

When there is no fallback either, turn the call away as busy:

{ "action": { "action": "reject", "reason": "busy" } }

A call for a room joins it with connectToConference. A call an app user placed to a phone number dials that number with connectToNumber. An inbound call to one of your numbers rings a preferred agent when they are online, otherwise the first agent who is, and falls back the same way.

Basic IVR

A menu plays a prompt and collects keypresses. Answer the call.request with playMenu:

{
  "action": {
    "action": "playMenu",
    "prompt": { "text": "Press 1 for sales, 2 for support" },
    "maxDigits": 1,
    "timeoutSeconds": 5,
    "repeats": 2
  }
}

The keypress comes back as a call.input question for the same callId:

{
  "type": "call.input",
  "version": "1",
  "callId": "call_9f2ab000-0000-4000-8000-000000000001",
  "number": "+38349111222",
  "timestamp": "2026-07-22T12:00:07+00:00",
  "digits": "1"
}

Branch on digits. The example app maps a key to an app user, a room, a hold, or a hangup. 1 rings an app user:

{ "action": { "action": "connectToUser", "identity": "ben", "record": false } }

2 puts the caller into a room:

{ "action": { "action": "connectToConference", "name": "daily-standup", "record": false } }

digits is an empty string when the menu timed out. For an empty or unknown value, say so, count the attempt in a cookie, and play the menu again:

{
  "instructions": [
    { "instruction": "say", "text": "That was not a valid choice." },
    { "instruction": "setCookie", "key": "menuAttempts", "value": "2" }
  ],
  "action": {
    "action": "playMenu",
    "prompt": { "text": "Press 1 for sales, 2 for support" },
    "maxDigits": 1,
    "timeoutSeconds": 5,
    "repeats": 2
  }
}

The cookie comes back on the next call.input, so your callback can read menuAttempts and give up after the second attempt:

{
  "instructions": [
    { "instruction": "say", "text": "Goodbye." }
  ],
  "action": { "action": "hangup" }
}

Menus chain. A menu that collects several digits, such as an account number, sets maxDigits and uses a cookie to remember 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
  }
}

Every cookie set earlier in the call comes back on the next question, so the callback stays stateless:

{
  "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" }
}

Hold

To hold a call, answer park with an intro prompt played once, a hold prompt that repeats, and how long to hold for:

{
  "action": {
    "action": "park",
    "introPrompt": { "text": "Please hold" },
    "holdPrompt": { "text": "Still with you" },
    "maxDurationSeconds": 600
  }
}

The example app holds for 120 seconds, both as an answer to an incoming call and as a menu key. A hold is a bounded wait: when it reaches maxDurationSeconds the call ends, so say how long the caller may wait in the prompts and keep maxDurationSeconds honest. One hold can last at most 600 seconds; a larger value is refused with park_duration_exceeded.

Record a Call

Set record: true on the connect action to record a call to a phone number or a room:

{
  "action": {
    "action": "connectToNumber",
    "number": "+38349123456",
    "callerId": "+14155551234",
    "dialTimeoutSeconds": 30,
    "record": true
  }
}

The recording starts when the legs join, so everything from that moment is captured. A startRecording instruction records from the moment it runs instead, and a stopRecording instruction ends a recording early. The example app's routing panel does both: a record switch that sets record: true on every connect, and a free list of instructions it sends before the action.

{
  "instructions": [
    { "instruction": "say", "text": "This call may be recorded.", "locale": "en-US" },
    { "instruction": "startRecording" }
  ],
  "action": { "action": "connectToConference", "name": "daily-standup", "record": false }
}

A call connected to an app user cannot be recorded, whether the caller is a phone or another app user. record: true on connectToUser is accepted and the call connects without a recording, and a startRecording instruction in the same answer is left out. Once a recording is stored, a call.recording_ready webhook arrives with its recording_id, and GET /v3/calls/{id}/recordings lists it with a download link. When call recording is not available, the start is left out and the call carries on, and POST /v3/calls/{id}/recordings answers 403 with BUSINESS_026. See Record a Call.

Room Per Call

To add people to a call later, or to transfer it, answer with a room named after the call instead of connecting the two parties directly. The example app derives the name from the call id, r followed by the first 26 hex characters of the id, which fits the 27 character limit:

{ "action": { "action": "connectToConference", "name": "r9f2ab000000040008000000000", "record": false } }

Then, from your backend, dial the intended party into the room with POST /v3/calls/{id}/participants:

curl -X POST "https://api.sent.dm/v3/calls/call_9f2ab000-0000-4000-8000-000000000001/participants" \
  -H "x-api-key: $SENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": { "kind": "user", "value": "ben" }
  }'

Sent records the room on the call as it reads your answer, so a request sent right after answering can see 409 with CONFLICT_009 for a moment. The example app waits a second and retries, up to eight times. Do not make this request inside the callback handler; Sent is waiting for your answer there.

Each participant is a call of its own, with its own id. Mute, remove or transfer with the participant endpoints, and read who is in the room with GET /v3/calls/{id}/participants. See Conference Participants.

Department Routing

Give each department its own voice number, and mint your agents' tokens with number set to their department's line. See Route by Department.

Every question carries number, the line the call belongs to. On an inbound call.request it is the number the caller dialed:

{
  "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" }
}

Branch on number. Ring an agent of that department who is online with connectToUser. When nobody is online, dial the department's fallback phone with connectToNumber, and when there is no fallback either, reject the call as busy, exactly as in Ring an App User, Fall Back to a Phone. callerId must be a number you hold through Sent; the call's own number is used when it is omitted.

Next Steps

On this page