Documentation menu

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:

  1. Your backend calls POST /api/phone-tokens/mint with your secret API key, passing the end-user's uuid.
  2. The mint response returns a token and an iceServers config (STUN/TURN for NAT traversal). Your backend hands only those to the browser.
  3. 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.

OptionDefaultNotes
logLevel'none''error' | 'info' | 'debug'. Logs to the console prefixed [voice-sdk]; 'debug' also enables JsSIP wire tracing.
onLogCustom sink (level, ...args) => void. When set, log lines go here instead of the console — route them into your own UI/telemetry.
iceGatheringTimeout3000Fallback 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.
iceServerspublic STUNFallback ICE config. In production pass the per-session config from your mint response to authenticate instead.
wssUrlproductionOverride only when testing against another box.
realmproductionSIP domain.

Methods

  • authenticate(token | { token, iceServers })Promise<void> — registers over WSS; resolves on success, rejects if registration fails.
  • call(destination, { customPayload? })Calldestination is 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 a Call's ended / failed reason 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 fallback Phone uses when you don't pass iceServers.

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 contextHTTPS 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

SymptomLikely cause
authenticate rejects immediatelyToken expired or malformed. Mint a fresh one — tokens are short-lived by design.
registrationFailedToken revoked, the account is over budget, or the origin isn't permitted. Check the surfaced SIP reason.
Registers, but calls have no audioInsecure context (not HTTPS), or no reachable ICE candidate. Serve over HTTPS and pass iceServers from the mint response.
Long stall before a call connectsUDP-restricted network with no TURN relay. Ensure your mint response includes TURN iceServers.
Nothing in the consoleDefault 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.