Documentation menu

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.

PurposeTypeFired whenYou return
voice_inboundcall-controlA PSTN call arrives on one of your numbers.A verb document.
voice_outboundcall-controlA browser/mobile end-user places an outbound call, or you originate one via the API.A verb document.
statusstatus callbackAny 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"
  }
}
FieldMeaning
CallSidUnique id for this call. Use it to correlate with events and call records.
AccountSidYour account's uuid.
Directioninbound for a call to your number, outbound for one an end-user or the API originated.
FromCaller's number (inbound), or the caller-id you present (outbound).
ToYour number that was dialled (inbound), or the destination (outbound).
CallStatusringing on an inbound call, in-progress on an outbound one.
customPayloadPresent 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.
endUserPresent 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 fieldMeaning
DialCallStatusOutcome of the bridge: completed, busy, no-answer, or failed.
DialCallDurationSeconds the dialled leg was connected (0 unless it answered).

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

Extra fieldMeaning
RecordingDurationSeconds of audio captured.
RecordingPathStorage 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"
  }
}
FieldMeaning
idUnique id for this delivery. The same event is retried with the same id, so use it to ignore duplicates.
codeStable machine code — switch on this. See Events & codes.
eventHuman-readable name, also sent in the X-Voice-Event header.
sidThe call this event is about. Matches the CallSid from the call-control request.
customPayloadYour metadata, echoed verbatim — present only when you set one.
dataCode-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.initiatedCALL.0100, call.answeredCALL.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:

30s60s120s240s480s — 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.
  • 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:

  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

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:

ModeBehaviour
messageSpeak a short TTS message, then hang up.
playPlay an audio file from a URL, then hang up.
dropHang 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.)