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.
You may not need a server
Section titled “You may not need a server”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.
The three webhook routes
Section titled “The three webhook routes”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. |
Call-control: the request we send
Section titled “Call-control: the request we send”When a call needs handling we send an HTTP POST with a JSON body and two signature headers:
POST /handle-call HTTP/1.1Content-Type: application/jsonX-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.
Call-control: action callbacks
Section titled “Call-control: action callbacks”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.
Status callbacks
Section titled “Status callbacks”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.1Content-Type: application/jsonX-Voice-Signature: <base64 HMAC-SHA256>X-Voice-Timestamp: 1719900000X-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.
Delivery and retries
Section titled “Delivery and retries”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.
Verify the signature
Section titled “Verify the signature”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)Rotating the secret
Section titled “Rotating the secret”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.
Configuring URLs
Section titled “Configuring URLs”For a given purpose, we resolve which URL to call in this order:
- A per-call
url(for outbound calls, theurlyou passed to/calls/originate). - A per-number URL override, set with
POST /numbers/configure. - 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 dialleddef 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.