↑ ↓ to navigate · ↵ to open
Documentation menu
Getting started
Building calls
Operate
Building calls
Browser SDK
@sauti-pbx/voice-sdk embeds a working softphone in your web app. Drop in a
<script> tag (or npm install in a bundler app), authenticate with a
short-lived token minted by your backend, and you have a phone — call, answer,
hold, mute, DTMF, device selection, and rich local call events. It runs entirely in the browser
over WebRTC; no plugin, no native app.
Install
Script tag — no build step; exposes a VoiceSDK browser global:
<script src="https://cdn.jsdelivr.net/npm/@sauti-pbx/voice-sdk@0.1.3/dist/voice.iife.js"></script>
Bundler (React / Vue / Vite / webpack):
npm install @sauti-pbx/voice-sdk
import { Phone } from '@sauti-pbx/voice-sdk';
JsSIP is bundled into the package, so a single script tag is a complete, working phone — nothing else to load. Pin the exact version while you test; CDNs cache aggressively.
The token flow (read this first)
Your secret API key never touches the browser. The browser only ever holds a short-lived, single-extension, revocable phone token. Three steps:
- Your backend calls
POST /api/phone-tokens/mintwith your secret API key, passing the end-user'suuid. - The mint response returns a
tokenand aniceServersconfig (STUN/TURN for NAT traversal). Your backend hands only those to the browser. - The browser passes them to
phone.authenticate(...).
Minting on your server (never in the browser):
# your backend — the secret key stays here
curl https://sauti-pbx.services.co.ke/api/phone-tokens/mint \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "uuid": "the-end-user-uuid" }'
# response
{
"token": "eyJhbGc...",
"phone_token": { "uuid": "...", "expires_at": "...", /* ... */ },
"iceServers": [ { "urls": "stun:..." }, { "urls": ["turn:..."], "username": "...", "credential": "..." } ]
}
The response keys line up with the SDK, so you can hand the browser the token and
iceServers and pass them straight to authenticate() — no field remapping.
Treat the token like a short-lived credential: never mint in the browser, and never log it. If one leaks it expires within minutes, works for a single extension only, and you can kill it instantly with POST /api/phone-tokens/revoke.
Quickstart
A complete phone — outbound and inbound — in one script tag:
<script src="https://cdn.jsdelivr.net/npm/@sauti-pbx/voice-sdk@0.1.3/dist/voice.iife.js"></script>
<script>
const phone = new VoiceSDK.Phone({ logLevel: 'info' });
// { token, iceServers } came from YOUR backend's mint call
await phone.authenticate({ token, iceServers });
// outbound
const call = phone.call('+254711111111', { customPayload: '{"caseId":"CASE-0042"}' });
call.on('ringing', () => console.log('ringing…'));
call.on('accepted', () => console.log('connected'));
call.on('ended', (r) => console.log('ended', r));
// inbound
phone.on('incoming', (incoming) => {
console.log('call from', incoming.remoteIdentity);
incoming.answer(); // or incoming.reject()
});
</script>
Want to see it running before you write any code? Open the reference softphone at
/softphone/ — a debug harness that exercises the same
token + WebRTC path against the live platform.
The Phone object
Create one Phone, authenticate it once, then place and receive calls on it.
| Option | Default | Notes |
|---|---|---|
| logLevel | 'none' | 'error' | 'info' | 'debug'. Logs to the console prefixed [voice-sdk]; 'debug' also enables JsSIP wire tracing. |
| onLog | — | Custom sink (level, ...args) => void. When set, log lines go here instead of the console — route them into your own UI/telemetry. |
| iceGatheringTimeout | 3000 | Fallback cap (ms) on ICE gathering. Normally the call sends the instant a TURN relay candidate appears (sub-second); this only bites when no relay arrives. 0 waits for full gathering. |
| iceServers | public STUN | Fallback ICE config. In production pass the per-session config from your mint response to authenticate instead. |
| wssUrl | production | Override only when testing against another box. |
| realm | production | SIP domain. |
Methods
authenticate(token | { token, iceServers })→Promise<void>— registers over WSS; resolves on success, rejects if registration fails.call(destination, { customPayload? })→Call—destinationis a bare extension/number ('2002','+254711111111') or a full SIP URI.unregister()— unregister and close the connection.listDevices()→{ inputs, outputs }setInputDevice(id)/setOutputDevice(id)/setVolume(0..1)- Getters:
extension,account,isRegistered
Events
registered, unregistered, registrationFailed, connected, disconnected, incoming. The incoming event hands you a Call.
The Call object
Returned by phone.call(...) (outbound) and by the incoming event (inbound). One Call is one call leg.
Methods
answer()/reject()— answer or decline an incoming call (reject sends SIP 486 Busy).hangup()— end an active or outgoing call.hold()/unhold()mute()/unmute()— toggles the local microphone.sendDigit(tone)— DTMF (RFC 2833), e.g. navigating an IVR.- Getters:
direction,remoteIdentity,isOnHold,isMuted
Events
ringing, accepted, ended, failed, hold, unhold, muted, unmuted. On ended / failed the SIP status line / cause is surfaced so you can show why a call dropped.
customPayload rides the call as the X-Sauti-Custom-Payload header (max 500 chars, no newlines) and surfaces in your event/webhook pipeline — a clean way to attach your own context (a case id, a ticket number) to a call.
Other exports
Besides Phone and Call, the package exports a few helpers:
describeFailure(reason)— turns aCall'sended/failedreason into a one-line human summary, e.g."402 Insufficient balance · cause=Rejected". Handy for surfacing exactly why a call was refused (insufficient balance, a cap, an offline callee) in your UI.decodeToken(token)/isExpired(token)— inspect a phone token's claims and expiry client-side, without a network round-trip.DEFAULT_ICE_SERVERS— the built-in public-STUN fallbackPhoneuses when you don't passiceServers.
Events: local here, platform truth on your backend
The SDK surfaces local call events — the lifecycle above, observed directly in the browser on the phone's own leg, with no server round-trip. That covers everything a phone UI needs.
Platform-truth events the browser can't know — call cost, recording ready + URL, billing, and bridged / far-leg state — are delivered to your backend via voice webhooks or a backend event-stream subscription, where your CDR and billing logic lives. That split is deliberate: it keeps the client lean and avoids exposing one end-user's call data in another's browser tab.
Secure context is required
WebRTC microphone capture only works in a secure context — HTTPS or
http://localhost. On a plain http://<LAN-IP> origin, registration can
succeed but calls silently fail (no mic). The SDK throws a clear error when you try to call from an
insecure context — serve your app over HTTPS.
For NAT traversal, always pass the per-session iceServers from your mint response. The
built-in default is a public STUN server — enough on cooperative networks, but symmetric-NAT and mobile
need the TURN relay that the mint response provides.
Troubleshooting
| Symptom | Likely cause |
|---|---|
authenticate rejects immediately | Token expired or malformed. Mint a fresh one — tokens are short-lived by design. |
registrationFailed | Token revoked, the account is over budget, or the origin isn't permitted. Check the surfaced SIP reason. |
| Registers, but calls have no audio | Insecure context (not HTTPS), or no reachable ICE candidate. Serve over HTTPS and pass iceServers from the mint response. |
| Long stall before a call connects | UDP-restricted network with no TURN relay. Ensure your mint response includes TURN iceServers. |
| Nothing in the console | Default logLevel is 'none'. Set 'info' or 'debug', or wire an onLog sink. |
Status: 0.1.x, under test. The API may change before 1.0.