Documentation menu

API reference

Events & codes

Watch calls happen in real time over a WebSocket. Every event carries a stable code — the same code appears on the event stream, in webhooks, and on the call record — so you switch on one value everywhere.

Connecting

The socket is authenticated with a short-lived stream token, not your API key. Mint one on your backend, hand it to the browser, then open the socket:

// 1. your backend mints a token (keeps the API key private)
//    POST /api/stream/token  ->  { "token": "...", "ttl_seconds": 60 }

// 2. the browser opens the socket with that token
const ws = new WebSocket(
  "wss://sauti-pbx.services.co.ke/api/stream?token=" + token
);
ws.onmessage = (e) => {
  const ev = JSON.parse(e.data);
  console.log(ev.code, ev.event, ev.sid);
};

The token only lasts long enough to open the socket (see ttl_seconds), so mint it right before connecting. The socket is scoped to your account — you only ever receive your own events.

The event envelope

Every frame is a small JSON object. Keys that would be empty are simply left out:

{
  "code":      "CALL.0200",        // stable machine code — switch on this
  "event":     "call.answered",    // human-readable name
  "sid":       "call_...",         // present when the event is about a call
  "customPayload": { },              // present only if you set one on the call
  "createdAt": "2026-07-05T10:00:00Z",
  "data":      { }                    // code-specific fields
}

A call that ends carries its terminal outcome inside data — for example a call.completed event whose data includes reasonCode: "END.0200". That same reason code is stored on the call record (as reason_code).

This is the lean frame the WebSocket delivers — the socket is already scoped to your account, so accountId and a per-event id are left out. The same events delivered as status-callback webhooks add those two keys (for de-duplicating retries) and carry the full call snapshot in data. Everything else is identical, so you switch on code the same way on both transports.

Event codes

Codes pushed on the stream (and, where durable delivery matters, as webhooks). The numeric suffix mirrors familiar SIP/HTTP semantics so the meaning is guessable.

CodeEventMeaning
CALL.0100 call.initiated A call leg was created but is not yet ringing.
CALL.0180 call.ringing The remote party is being alerted (ringing).
CALL.0183 call.progress Early media / "connecting…" — audio before answer, distinct from ringing.
CALL.0200 call.answered The call was answered/bridged; the leg is connected and billing has started.
CALL.0300 call.completed The call ended after connecting. Carries the terminal reasonCode + duration/cost.
CALL.0400 call.failed The call did not complete (never connected, or was refused/cut). Carries the terminal reasonCode explaining exactly why.
DIAL.0200 dial.answered A <Dial> bridged leg was answered by the far end.
DIAL.0300 dial.completed A <Dial> bridged leg ended; carries its DialCallStatus + duration.
DIAL.0403 dial.blocked A <Dial> was blocked by the outbound destination allowlist (anti-fraud, §4.6). The parent call continues, so this is an event on the dial attempt, not a call end.
PLAY.0404 play.asset_missing A <Play asset="…"> named an asset that does not exist in this account, is inactive, or has no playable audio. The call continues with the next verb. This event exists because the failure is otherwise invisible: a failed playback does not end the channel, so the caller simply hears silence.
REC.0100 recording.started Audio capture began for the call.
REC.0300 recording.completed A recording finished and its Recording row is available.
TTS.0200 tts.generated A <Say> utterance was synthesised; carries seconds + tier used (billed separately).
WH.0200 webhook.delivered A status-callback / voice webhook was delivered successfully.
WH.0400 webhook.failed A webhook delivery failed after retries.
AUTH.0200 phone.authorized An end-user phone token was validated and accepted.
AUTH.0401 phone.rejected An end-user phone token was expired or revoked.
BILL.0300 wallet.debited A usage charge was posted to the wallet after a call/segment settled.
BILL.0310 wallet.low The live monitor warns the account is nearing its allowance on active calls.
BILL.0402 wallet.insufficient A new call was refused before answer because the account has no spendable allowance.
BILL.0403 wallet.exhausted The live billing monitor terminated the call because the allowance ran out mid-call.
BILL.0407 account.suspended A new call was refused because the account is not active/suspended.
LIMIT.0429 call.concurrency_exceeded A new call was refused: the account already has its maximum simultaneous calls.
LIMIT.0430 call.cps_exceeded A new call was refused: the account is placing calls faster than its CPS limit.
END.0403 call.no_did An end-user tried to place an outbound PSTN call but the account owns no active DID to present as caller ID. Acquire/activate a number first.
END.0488 call.cross_account An end-user dialled an extension belonging to a different account. On-net calls are only allowed between extensions of the same account.
END.0410 call.callee_offline An end-user dialled an on-net extension whose softphone is not currently registered (offline). The callee must be online to receive the call.
END.0501 call.no_outbound_handler An end-user placed an outbound PSTN call but the account has no outbound voice webhook configured. SautiPBX never dials the PSTN on its own — call control comes from your <Response> XML — so the caller heard a short notice and the call was ended. Configure an outbound voice webhook to place the call.
PLATFORM.0001 platform.concurrent The current platform-wide active-call count (staff stream).
PLATFORM.0503 platform.draining The call was refused because the platform is briefly not admitting new calls — an operator closed the call gate for a deploy or maintenance window. It is not a problem with your account, your balance or your request: calls already in progress continue, and the gate reopens automatically within minutes. Retry shortly.
SYS.0500 system.error An internal error occurred handling a call event; carries a traceId to correlate.

Rows marked are also terminal reason codes: you receive them as an event and they explain why a call ended.

Reason codes

Terminal call outcomes. Every call record's reason_code is one of these, and it is what a call.completed / call.failed event carries in data.reasonCode.

CodeOutcomeMeaning
BILL.0402 Insufficient balance A new call was refused before answer because the account has no spendable allowance.
BILL.0403 Balance exhausted mid-call The live billing monitor terminated the call because the allowance ran out mid-call.
BILL.0407 Account suspended A new call was refused because the account is not active/suspended.
LIMIT.0429 Concurrency cap reached A new call was refused: the account already has its maximum simultaneous calls.
LIMIT.0430 Calls-per-second limit exceeded A new call was refused: the account is placing calls faster than its CPS limit.
END.0200 Normal hangup A party hung up normally after a connected call.
END.0404 Unallocated / inactive number The dialled number is not a live DID on the platform (unknown or inactive account).
END.0408 Maximum duration reached The call hit the account's absolute maximum-duration ceiling and was cut.
END.0480 No answer The call rang out with no answer.
END.0486 Busy The far end was busy.
END.0487 Canceled before answer The caller hung up before the call was answered.
END.0503 Enforcement watchdog cut The enforcement watchdog self-destruct fired because the billing monitor was unreachable — the leg was cut to bound outage exposure (§4.5).
END.0504 Reconciled from the switch The call ended on FreeSWITCH but its hangup never reached us — the event consumer was restarting, or its ESL socket dropped — so the CDR was closed by the reconcile sweep against the switch's live channel list. `endedAt` is when we detected the loss, not when the carrier released the call; the sweep runs within a minute of the gap (and immediately on reconnect), so it is close, but it is a detection time. Talk time is billed only when the dialled leg already reported it — never guessed.
END.0500 Carrier / network failure The carrier or network failed the call (temporary failure, out-of-order, bearer mismatch).
END.0603 Rejected The leg was rejected before answer (e.g. a malformed/unattributable outbound leg).
END.0403 No DID to originate from An end-user tried to place an outbound PSTN call but the account owns no active DID to present as caller ID. Acquire/activate a number first.
END.0488 Cross-account extension An end-user dialled an extension belonging to a different account. On-net calls are only allowed between extensions of the same account.
END.0410 Extension offline An end-user dialled an on-net extension whose softphone is not currently registered (offline). The callee must be online to receive the call.
END.0501 No outbound voice webhook An end-user placed an outbound PSTN call but the account has no outbound voice webhook configured. SautiPBX never dials the PSTN on its own — call control comes from your <Response> XML — so the caller heard a short notice and the call was ended. Configure an outbound voice webhook to place the call.
PLATFORM.0503 Platform paused for maintenance The call was refused because the platform is briefly not admitting new calls — an operator closed the call gate for a deploy or maintenance window. It is not a problem with your account, your balance or your request: calls already in progress continue, and the gate reopens automatically within minutes. Retry shortly.