↑ ↓ to navigate · ↵ to open
Documentation menu
Reference
Resources
Account
Realtime
More
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.
| Code | Event | Meaning |
|---|---|---|
| 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.
| Code | Outcome | Meaning |
|---|---|---|
| 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. |