Answering Calls
Every call on one of your voice numbers is decided by your own backend. When a call starts, Sent POSTs a question to the number's callback URL and follows the answer: ring an app user, dial a number, play a menu, hold, or hang up. This guide covers the loop, how to verify that a question came from Sent, the deadline your endpoint has to meet, and how to test the URL before a real call reaches it.
Prerequisites
Phone calls must be enabled for your account. This is set by Sent: contact support@sent.dm to request it.
You also need:
- A number with calls turned on and a callback URL stored on it. See Voice in the channels guide.
- A public HTTPS URL for that callback (HTTP is accepted too, but use HTTPS). Sent only calls URLs that resolve to a public address, so a tunnel is needed even on one laptop.
- A webhook subscribed to
callevents, so you learn when a call is answered, ends, or has a recording ready. See thecallevents.
How a Call Is Answered
Sent asks, your backend answers, and the call does what the answer says. There are two questions.
call.requestwhen a call starts: someone dialed your number, or a user of your app placed a call. Your answer decides where the call goes.call.inputwhen a caller presses keys on a menu you played. Your answer decides what happens next, which is how menus chain.
A call.request for a call placed from your app to a phone number looks like this:
{
"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" }
}callId is Sent's id for the call. It is the same id on every later question, on every call.* webhook, and on GET /v3/calls/{id}. number is your voice number that owns the call: the dialed number for an inbound call, the caller's bound number for a call placed from your app, or your default number for app calls when the caller has none bound.
An answer carries optional instructions, which run in order, and exactly one action, which decides where the call goes. The shortest valid answer rings an app user:
{ "action": { "action": "connectToUser", "identity": "ben", "record": false } }Answer with HTTP 200 and a JSON body. The Voice Callback Contract lists every question field, every action, every instruction, and the rules each one is checked against.
Three casings meet here. The question and answer use camelCase (callId, dialTimeoutSeconds). The REST API and the call.* webhooks use snake_case (call_id, duration_seconds). A handler that reads call_id off a question gets nothing.
Verify the Signature
Every question is signed, the same way your webhooks are. Verify it before you act on the question.
| Header | What it carries |
|---|---|
X-Webhook-ID | The id of the voice number's callback configuration. Not the number, and not one of your webhook ids |
X-Webhook-Timestamp | Unix seconds at which this attempt was signed |
X-Webhook-Signature | v1, followed by the Base64 HMAC-SHA256 of {id}.{timestamp}.{rawBody} |
X-Request-Id | Sent's id for this request, for support |
X-Sent-Retry | 1 on the one retry Sent makes, absent on the first attempt |
User-Agent | SentDM-Voice/1.0 |
The key is the number's callback_secret, returned once by POST /v3/channels/voice and again by POST /v3/channels/voice/{number}/rotate-secret. Strip the whsec_ prefix and Base64-decode the rest to get the raw key bytes. Each voice number has its own secret, so pick the one for the number named in the question's number field. The scheme is the one your webhooks already use, described in Webhook Security.
Sign over the raw body exactly as it arrived. Parsing and re-serializing the JSON changes the bytes and the check fails. Reject a timestamp more than 5 minutes away from your clock, which stops a captured question from being replayed later. Answer 401 to a question that does not verify: Sent treats a 4xx as your refusal of the call and does not retry.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
function verifySentSignature(headers, rawBody, secret) {
const id = headers['x-webhook-id'];
const timestamp = headers['x-webhook-timestamp'];
const signature = headers['x-webhook-signature'];
if (!id || !timestamp || !signature) return false;
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signature
.split(/\s+/)
.filter((part) => part.startsWith('v1,'))
.some((part) => {
const provided = Buffer.from(part.slice(3), 'base64');
return provided.length === expected.length && timingSafeEqual(provided, expected);
});
}
const app = express();
// Raw body: the signature covers the bytes as sent, so do not use express.json() on this route
app.post('/voice/callback', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const question = JSON.parse(rawBody);
const secret = secretForNumber(question.number); // the callback secret of that voice number
if (!verifySentSignature(req.headers, rawBody, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
res.json({ action: { action: 'connectToUser', identity: 'agent-42' } });
});import base64, hashlib, hmac, time
from flask import Flask, request, jsonify
TOLERANCE_SECONDS = 300
def verify_sent_signature(headers, raw_body: bytes, secret: str) -> bool:
webhook_id = headers.get("X-Webhook-ID")
timestamp = headers.get("X-Webhook-Timestamp")
signature = headers.get("X-Webhook-Signature")
if not webhook_id or not timestamp or not signature:
return False
try:
skew = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if skew > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{webhook_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
for part in signature.split():
if not part.startswith("v1,"):
continue
try:
provided = base64.b64decode(part[3:])
except ValueError:
continue
if hmac.compare_digest(provided, expected):
return True
return False
app = Flask(__name__)
@app.post("/voice/callback")
def voice_callback():
raw_body = request.get_data() # the bytes as sent, before any parsing
question = request.get_json(force=True)
secret = secret_for_number(question["number"]) # the callback secret of that voice number
if not verify_sent_signature(request.headers, raw_body, secret):
return jsonify(error="Invalid signature"), 401
return jsonify(action={"action": "connectToUser", "identity": "agent-42"})package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
func verifySentSignature(h http.Header, rawBody []byte, secret string) bool {
id := h.Get("X-Webhook-ID")
timestamp := h.Get("X-Webhook-Timestamp")
signature := h.Get("X-Webhook-Signature")
if id == "" || timestamp == "" || signature == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, part := range strings.Fields(signature) {
if !strings.HasPrefix(part, "v1,") {
continue
}
provided, err := base64.StdEncoding.DecodeString(part[3:])
if err == nil && hmac.Equal(provided, expected) {
return true
}
}
return false
}
func voiceCallback(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body) // the bytes as sent, before any parsing
var question struct {
Number string `json:"number"`
}
_ = json.Unmarshal(rawBody, &question)
secret := secretForNumber(question.Number) // the callback secret of that voice number
if !verifySentSignature(r.Header, rawBody, secret) {
http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"action":{"action":"connectToUser","identity":"agent-42"}}`))
}using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
const int ToleranceSeconds = 300;
static bool VerifySentSignature(IHeaderDictionary headers, string rawBody, string secret)
{
string? id = headers["X-Webhook-ID"];
string? timestamp = headers["X-Webhook-Timestamp"];
string? signature = headers["X-Webhook-Signature"];
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature))
return false;
if (!long.TryParse(timestamp, out var ts)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > ToleranceSeconds)
return false;
var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret["whsec_".Length..] : secret);
var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{rawBody}"));
foreach (var part in signature.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (!part.StartsWith("v1,")) continue;
byte[] provided;
try { provided = Convert.FromBase64String(part[3..]); } catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(provided, expected)) return true;
}
return false;
}
app.MapPost("/voice/callback", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var rawBody = await reader.ReadToEndAsync(); // the body as sent, before any parsing
var number = JsonDocument.Parse(rawBody).RootElement.GetProperty("number").GetString();
var secret = SecretForNumber(number); // the callback secret of that voice number
if (!VerifySentSignature(request.Headers, rawBody, secret))
return Results.Json(new { error = "Invalid signature" }, statusCode: 401);
return Results.Json(new { action = new { action = "connectToUser", identity = "agent-42" } });
});<?php
const TOLERANCE_SECONDS = 300;
function verifySentSignature(array $headers, string $rawBody, string $secret): bool
{
$id = $headers['X-Webhook-ID'] ?? '';
$timestamp = $headers['X-Webhook-Timestamp'] ?? '';
$signature = $headers['X-Webhook-Signature'] ?? '';
if ($id === '' || $timestamp === '' || $signature === '') {
return false;
}
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$encoded = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret;
$key = base64_decode($encoded, true);
if ($key === false) {
return false;
}
$expected = hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true);
foreach (preg_split('/\s+/', $signature) as $part) {
if (!str_starts_with($part, 'v1,')) {
continue;
}
$provided = base64_decode(substr($part, 3), true);
if ($provided !== false && hash_equals($expected, $provided)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input'); // the body as sent, before any parsing
$question = json_decode($rawBody, true);
$secret = secretForNumber($question['number']); // the callback secret of that voice number
$headers = [
'X-Webhook-ID' => $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '',
'X-Webhook-Timestamp' => $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
'X-Webhook-Signature' => $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
];
if (!verifySentSignature($headers, $rawBody, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
header('Content-Type: application/json');
echo json_encode(['action' => ['action' => 'connectToUser', 'identity' => 'agent-42']]);The signature header can carry more than one v1, value separated by spaces, which is why each snippet checks every part. Sent sends one today.
Deadlines and Retries
A caller is waiting while your endpoint decides, so the deadline is short.
| Limit | Value |
|---|---|
| Time per attempt, including DNS, connecting, and reading the body | 2.5 seconds |
| Attempts to the same URL | 2 |
| Largest response body | 64 KiB |
A failed attempt is anything other than a 2xx with a body Sent can read as an answer: a timeout, a connection failure, a 3xx (redirects are not followed), a 5xx, a body over 64 KiB, a callback URL Sent will not call, or an answer that fails validation. Sent asks once and retries once, with X-Sent-Retry: 1, unless the first attempt answered with a 4xx. A 4xx is your refusal of the call and is not retried. The retry carries the same callId, so a handler that saw the first attempt can answer the same way again.
The outcome is whatever the last attempt produced, and the two attempts need not fail the same way. A first attempt with an unreadable body followed by a second that times out ends as callback_timeout; a first attempt that times out followed by a 4xx ends as rejected.
Do no network calls inside the handler. Decide from what you already know: who is online, which department a number belongs to, what the caller pressed. The example app keeps its routing rules in memory for this reason, and anything slower than the deadline, such as looking a caller up in a remote CRM, belongs in a cache your handler reads rather than a call it makes.
What Happens When Your Callback Fails
What a failure does to the call depends on which question it was.
- On
call.requestthe call has not been answered yet, so it fails. The call ends withstatusFAILEDand you receivecall.failedwith areasontaken from the last attempt:callback_timeoutwhen it timed out, could not connect, was answered with a3xxor a5xx, returned a body over 64 KiB, or the URL was one Sent will not call;invalid_answerwhen it returned a2xxwith a body that was not an answer Sent could use;rejectedwhen it returned a4xx. Acallback_timeoutis not always a slow server: check the response size and that the URL answers directly, without a redirect. - On
call.inputthe call was already answered by the menu, so it is hung up instead. It ends withstatusCOMPLETED, is billed for the time it ran, and you receivecall.completed. The same holds for a4xxor arejectaction on acall.input.
Some calls are refused before any question is asked. A call placed from your app is hung up when your balance is at or below zero (insufficient_balance), whoever it is to: a phone number, another app user, or a room. A call placed from your app to a phone number in a country Sent does not allow calls to is hung up too (destination_blocked). Inbound calls to your numbers are not checked for balance before the question; a phone leg your answer adds to one is. A call on a number that has no callback URL stored is hung up as well (callback_not_configured). Each arrives as call.failed with that reason, after a call.initiated: that event is sent when the call arrives, before these checks run, so it does not mean your callback was asked. The full list is in Call Failure Reasons.
Keep State With Cookies
Your backend does not have to remember anything between questions. A setCookie instruction stores a value against the call, and every later call.input for that call carries the whole set under cookies. A later value replaces an earlier one under the same key, and the cookies live as long as the call does.
An answer that plays a menu and remembers 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
}
}The keypress comes back with everything stored so far:
{
"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" }
}digits is an empty string when the menu timed out without input. cookies is absent when nothing was stored yet.
Test Before the First Call
POST /v3/channels/voice/{number}/test sends a synthetic call.request to the number's callback URL, signed with that number's real secret, and reports what came back. No call is placed, nothing is billed, and nothing is stored. The question carries "test": true, which a real call never does, so a backend can treat a question without the field as a live call. The number goes in the path with its plus sign URL-encoded as %2B.
curl -X POST "https://api.sent.dm/v3/channels/voice/%2B12025550123/test" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'The verdict is outcome: ok when your endpoint answered 2xx with a valid answer, otherwise timeout, connection_failed, http_error, or invalid_answer. The response also shows the exact headers and body your endpoint received, so you can compare them against what it verified, and your answer as Sent read it, with a missing caller id filled in and numbers normalized.
{
"success": true,
"data": {
"outcome": "ok",
"call_id": "call_9f2ab000-0000-4000-8000-000000000001",
"request": {
"url": "https://example.com/voice",
"headers": {
"User-Agent": "SentDM-Voice/1.0",
"X-Webhook-ID": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"X-Webhook-Timestamp": "1784721600",
"X-Webhook-Signature": "v1,xJ/WfHm26o0BBWZ/SIDmZio7C3BbqlcbxvO/QIZpjUA=",
"X-Request-Id": "req_7X9zKp2jDw"
},
"body": "{\"type\":\"call.request\",\"version\":\"1\",\"callId\":\"call_9f2ab000-0000-4000-8000-000000000001\",\"number\":\"+12025550123\",\"timestamp\":\"2026-07-22T12:00:00+00:00\",\"test\":true,\"direction\":\"outbound\",\"from\":{\"kind\":\"user\",\"identity\":\"test-user\"},\"to\":{\"kind\":\"number\",\"number\":\"+12025550142\"}}"
},
"response": {
"status_code": 200,
"body": "{ \"action\": { \"action\": \"connectToUser\", \"identity\": \"agent-42\" } }"
},
"answer": {
"action": { "action": "connectToUser", "identity": "agent-42", "record": false }
},
"error": null
},
"error": null,
"meta": {
"request_id": "req_7X9zKp2jDw",
"timestamp": "2026-07-22T12:00:00+00:00",
"version": "v3"
}
}The X-Webhook-Signature value in this example is illustrative and does not verify against the body shown. A real response carries the signature computed over the exact body that was sent.
When the outcome is not ok, error says what went wrong: a machine-readable reason such as timeout, http_error, malformed_json, missing_action, or unknown_action, the dotted path of the answer field at fault when one field is to blame, and a message saying what to fix. call_id exists nowhere else and cannot be looked up.
A wrong secret shows up here as your endpoint answering 401 and the outcome http_error. Fix the secret your handler uses, not the signature check, and run the test again before taking a real call.
Next Steps
Voice Callback Contract
Every question field, action, instruction, and validation rule
Voice Recipes
Ring an app user with a phone fallback, a menu, a hold, a recorded call, a room per call, and routing by department
Calls, Recordings, and Participants
Read calls, hang up, record, and manage a conference room
Call Events
The call.* webhooks and their payload
Two-Way Conversations
How Sent handles two-way conversations, from inbound matching and storage to keyword detection, opt-out handling, auto-replies, and the conversation rules that gate free-form replies on SMS, RCS, and WhatsApp.
Calls From Your App
How a user of your web app places and receives phone calls with Sent: mint voice tokens on your backend, register the browser with the Sent voice SDK, and route calls by department.