Skip to content

Voice webhooks

SautiPBX uses two kinds of webhook. Call-control webhooks ask what to do with a live call and expect a verb document back. Status callbacks notify you after the fact that something happened. Both are HTTP POSTs, and both are signed so you can trust they came from us.

A call-control webhook has to return a verb document. It does not have to be your server that returns it. Write the document in the portal and point the webhook at the URL we host it on. No server, no deployment, no uptime to manage. It is the right choice for any flow that is the same on every call: a greeting, an announcement, an after-hours message, a number that always forwards to the same place.

See Assets for how to write one and get its URL.

Run your own server when the answer has to change per call. Routing by caller, a database lookup, an IVR that branches. The rest of this page is about that case. You can mix the two freely: a hosted document can <Redirect> to your server, and your server can <Redirect> to a hosted document.

Status callbacks always need a server of your own, because they report to you rather than ask you anything.

Every webhook belongs to one purpose. You can point each purpose at a different URL, or send them all to one endpoint and branch on the body.

Purpose Type Fired when You return
voice_inbound call-control A PSTN call arrives on one of your numbers. A verb document.
voice_outbound call-control A browser/mobile end-user places an outbound call, or you originate one by API. A verb document.
status status callback Any call lifecycle event (initiated, ringing, answered, completed, failed). 200 OK. The body is ignored.

When a call needs handling we send an HTTP POST with a JSON body and two signature headers:

POST /handle-call HTTP/1.1
Content-Type: application/json
X-Voice-Signature: <base64 HMAC-SHA256>
X-Voice-Timestamp: 1719900000
{
"CallSid": "call_...",
"AccountSid": "your-account-uuid",
"Direction": "outbound",
"From": "+254709080010",
"To": "+254711111111",
"CallStatus": "in-progress",
"customPayload": "{\"orderId\":\"A-42\"}",
"endUser": {
"uuid": "enduser-uuid",
"email": "agent@your-app.com",
"display_name": "Agent Smith",
"extension_number": "1010"
}
}
Field Meaning
CallSid Unique id for this call. Use it to correlate with events and call records.
AccountSid Your account’s uuid.
Direction inbound for a call to your number, outbound for one an end-user or the API originated.
From Caller’s number (inbound), or the caller-id you present (outbound).
To Your number that was dialled (inbound), or the destination (outbound).
CallStatus ringing on an inbound call, in-progress on an outbound one.
customPayload Present only when the caller attached metadata. The string you passed to /calls/originate, or a browser phone’s customPayload call option. Omitted when there is none.
endUser Present only on a browser-phone (end-user-originated) outbound call. Since From is your account’s shared DID, this is how you attribute the call to a specific user.

Respond with 200 OK and your verb document within 10 seconds. Any other status, a timeout, or invalid XML triggers the fallback.

Two limits apply to every call-control request, and to the action callbacks below:

Limit Value Past it
Time 10 seconds The request is abandoned and the fallback runs.
Body size 64 KB The request is abandoned and the fallback runs.

The 10 seconds covers the whole exchange, not each read: connecting, your handler thinking, and the body arriving all come out of one budget. A server that sends a byte at a time cannot hold the call open by staying just inside a socket timeout.

64 KB is a large verb document. If you are near it, you are probably building a long <Play> or <Say> list that a <Redirect> could split into two shorter responses.

Some verbs come back to your server mid-call to ask what to do next. When one does, we POST to its action URL (or, for <Redirect>, the redirect target) with all the fields above plus a few extras describing what just happened. You reply with the next verb document, exactly as with the first request.

<Dial action="...">: posted when the dialled leg ends:

Extra field Meaning
DialCallStatus Outcome of the bridge: completed, busy, no-answer, or failed.
DialCallDuration Seconds the dialled leg was connected (0 unless it answered).

<Record action="...">: posted when recording finishes:

Extra field Meaning
RecordingDuration Seconds of audio captured.
RecordingPath Storage reference for the audio. The downloadable Recording record is created moments later. List it via the recordings API.

<Redirect> re-posts the base call fields with no extras. It simply asks a different URL for the next document.

A status callback fires for every step of a call’s life so you can track it without polling. We POST the same event envelope you see on the event stream, plus two fields the socket omits: id (for de-duplicating retries) and accountId:

POST /call-events HTTP/1.1
Content-Type: application/json
X-Voice-Signature: <base64 HMAC-SHA256>
X-Voice-Timestamp: 1719900000
X-Voice-Event: call.completed
{
"id": "a1b2c3d4...",
"accountId": "your-account-uuid",
"code": "CALL.0300",
"event": "call.completed",
"sid": "call_...",
"customPayload": { "caseId": "CASE-0042" },
"createdAt": "2026-07-05T10:00:00Z",
"data": {
"sid": "call_...",
"direction": "inbound",
"from_number": "+254711111111",
"to_number": "+254709080010",
"status": "completed",
"reason_code": "END.0200",
"duration_seconds": 42,
"talk_seconds": 37,
"started_at": "2026-07-05T09:59:18Z",
"answered_at": "2026-07-05T09:59:23Z",
"ended_at": "2026-07-05T10:00:00Z"
}
}
Field Meaning
id Unique id for this delivery. The same event is retried with the same id, so use it to ignore duplicates.
code Stable machine code. Switch on this. See Events & codes.
event Human-readable name, also sent in the X-Voice-Event header.
sid The call this event is about. Matches the CallSid from the call-control request.
customPayload Your metadata, echoed verbatim. Present only when you set one.
data Code-specific fields. data.reason_code is the terminal outcome (mirrored on the call record); talk_seconds is billed talk time.

Which lifecycle events map to which codes (call.initiated → CALL.0100, call.answered → CALL.0200, and so on) is listed in full under Events & codes. The same catalogue is used by the stream, so a webhook consumer and a socket consumer see one contract.

Status callbacks are delivered with automatic retries. We treat any 2xx as success; anything else (or a timeout) is retried with exponential backoff:

30s → 60s → 120s → 240s → 480s 5 retries, ~15 min total
  • A successful delivery emits a webhook.delivered (WH.0200) event.
  • If all retries fail, we emit a webhook.failed (WH.0400) event and increment the endpoint’s failure counter.

Each attempt gets the same budget as a call-control request: 10 seconds for the whole exchange and 64 KB of response body. Past either one, the attempt is failed and retried on the schedule above.

A 2xx counts as success only once we have read your response cleanly to the end. A server that sends the status line and then stalls, or sends more than 64 KB, is a failed attempt even though the status said 200. Acknowledge with a short body.

Because retries reuse the same id, always make your handler idempotent: acknowledge fast with 200, then process asynchronously.

Debugging a delivery? The portal’s Webhooks page shows every attempt for an endpoint, including the response status and body your server returned.

Every webhook we send. Call-control, action callback, or status. Is signed the same way. The signature proves the request came from us and was not tampered with. It is computed over URL + timestamp + body using your account’s webhook signing secret (find it under Webhooks in the portal, or with GET /account/webhooks/secret).

To verify: take the raw request body exactly as received, prepend the request URL and the X-Voice-Timestamp, HMAC-SHA256 it with your secret, base64-encode, and compare. Using a constant-time comparison. Against X-Voice-Signature.

import base64, hashlib, hmac, time
def verify(secret, url, raw_body, signature, timestamp):
# reject anything older than 5 minutes (replay protection)
if abs(time.time() - int(timestamp)) > 300:
return False
signed = f"{url}{timestamp}".encode() + raw_body
digest = hmac.new(secret.encode(), signed, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(expected, signature)

You have one signing secret per account, shared by all three webhook routes. Rotate it with POST /account/webhooks/secret/rotate. The old secret stops verifying immediately, so roll it out to your servers first.

For a given purpose, we resolve which URL to call in this order:

  1. A per-call url (for outbound calls, the url you passed to /calls/originate).
  2. A per-number URL override, set with POST /numbers/configure.
  3. Your account-default URL for that purpose, set with POST /account/webhooks/endpoint.

Each purpose resolves independently. A per-number inbound override never affects your outbound or status routes. Configure all three from Settings → Webhooks & Signing, or set per-DID overrides from the number’s configure dialog.

Browser-phone outbound is webhook-required

Section titled “Browser-phone outbound is webhook-required”

When a browser-phone end-user places an outbound call, SautiPBX always calls your voice_outbound webhook for a verb document before dialling the PSTN. It never dials on its own. This is the same model as inbound and API-originated calls: call control comes from your <Response> XML.

A minimal outbound webhook that dials the number the user dialled, presenting your DID:

# The call body contains Direction="outbound", From=your DID, To=what the user dialled
def handle_outbound(request):
body = request.json()
return f"""<Response>
<Dial><Number>{body['To']}</Number></Dial>
</Response>"""

Return any verb document. You can screen the call, play an announcement, route to a queue, or reject it before a single second of PSTN time is used.