↑ ↓ to navigate · ↵ to open
Documentation menu
Getting started
Building calls
Operate
Building calls
Webhooks
SautiPBX talks to your server with two kinds of webhook: call-control webhooks ask your server 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.
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 via the API. | A verb document. |
| status | status callback | Any call lifecycle event (initiated, ringing, answered, completed, failed). | 200 OK — the body is ignored. |
One URL or separate? You can send all three purposes to a single endpoint and branch on Direction ("inbound" / "outbound") and the X-Voice-Event header (call.initiated etc.). Or route each purpose to a dedicated handler. Both patterns work — use whichever fits your architecture. All three share one signing secret and one fallback policy.
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.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. The user who placed the call — uuid, email, display_name, extension_number. 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.
When a caller attaches metadata, customPayload rides the call-control request (shown above) and every status callback and stream event, so your handler can read it the moment it decides how to route the call. Set it on /calls/originate (outbound) or as the browser SDK's customPayload call option; for inbound, set it yourself in the verb document.
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 or watch for a recording.completed event. |
<Redirect> re-posts the base call fields with no extras — it simply asks a different URL for the next document.
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.1
Content-Type: application/json
X-Voice-Signature: <base64 HMAC-SHA256>
X-Voice-Timestamp: 1719900000
X-Voice-Event: call.completed
{
"id": "a1b2c3d4...", // unique per event — dedup on this
"accountId": "your-account-uuid",
"code": "CALL.0300", // stable machine code — switch on this
"event": "call.completed",
"sid": "call_...",
"customPayload": { "caseId": "CASE-0042" }, // present only if you set one
"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. For call events it carries the call snapshot shown above; data.reason_code is the terminal outcome (mirrored on the call record) and talk_seconds is the 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 & retries
Status callbacks are delivered with automatic retries. We treat any 2xx as success; anything else (or a timeout) is retried on this schedule:
- 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. - After 5 consecutive failed deliveries, the route is auto-disabled and you're notified. A single successful delivery resets the counter. Re-enable a disabled route from the portal's Webhooks page.
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 — no need to add your own logging to see why a delivery was rejected.
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)
Sign the raw bytes of the body, before any JSON re-serialisation. We build the body with sorted keys and no extra whitespace, so re-encoding it yourself will change the bytes and break the check.
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
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
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.
If no voice_outbound webhook is configured, the caller hears a short notice ("Outbound calling is not set up for this account…") and the call ends immediately with reason code END.0501. Configure an outbound webhook before enabling browser-phone dialling.
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.
Fallback
If a call-control webhook cannot be reached or returns something we cannot use, we apply the account fallback so the caller is never left in silence. Set it with POST /account/webhooks:
| Mode | Behaviour |
|---|---|
| message | Speak a short TTS message, then hang up. |
| play | Play an audio file from a URL, then hang up. |
| drop | Hang up immediately (the default). |
Whenever a fallback fires, you also receive a webhook.failed event explaining why — see Events & codes. (Status callbacks have no fallback; they retry instead.)