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 call events, so you learn when a call is answered, ends, or has a recording ready. See the call events.

How a Call Is Answered

Sent asks, your backend answers, and the call does what the answer says. There are two questions.

  1. call.request when a call starts: someone dialed your number, or a user of your app placed a call. Your answer decides where the call goes.
  2. call.input when 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.

HeaderWhat it carries
X-Webhook-IDThe id of the voice number's callback configuration. Not the number, and not one of your webhook ids
X-Webhook-TimestampUnix seconds at which this attempt was signed
X-Webhook-Signaturev1, followed by the Base64 HMAC-SHA256 of {id}.{timestamp}.{rawBody}
X-Request-IdSent's id for this request, for support
X-Sent-Retry1 on the one retry Sent makes, absent on the first attempt
User-AgentSentDM-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.

LimitValue
Time per attempt, including DNS, connecting, and reading the body2.5 seconds
Attempts to the same URL2
Largest response body64 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.request the call has not been answered yet, so it fails. The call ends with status FAILED and you receive call.failed with a reason taken from the last attempt: callback_timeout when it timed out, could not connect, was answered with a 3xx or a 5xx, returned a body over 64 KiB, or the URL was one Sent will not call; invalid_answer when it returned a 2xx with a body that was not an answer Sent could use; rejected when it returned a 4xx. A callback_timeout is not always a slow server: check the response size and that the URL answers directly, without a redirect.
  • On call.input the call was already answered by the menu, so it is hung up instead. It ends with status COMPLETED, is billed for the time it ran, and you receive call.completed. The same holds for a 4xx or a reject action on a call.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

On this page