@sautipbx/voice-sdk
v0.2.1
Published
Browser SDK for placing and receiving web calls on the SautiPBX voice platform. Drop in a <script> tag (or npm install in a bundler app), authenticate with an ephemeral token from your backend, and you have a working softphone.
Maintainers
Readme
@sautipbx/voice-sdk
Browser SDK for placing and receiving web calls on the SautiPBX voice platform.
Drop in a <script> tag (or npm install in a bundler app), authenticate with an
ephemeral token minted by your backend, and you have a working softphone —
call, answer, hold, mute, DTMF, device selection, and rich local call events.
Status: 0.2.x, under test. API may change before 1.0.
Install
Script tag (no build step — exposes the VoiceSDK global):
<script src="https://cdn.jsdelivr.net/npm/@sautipbx/[email protected]/dist/voice.iife.js"></script>Bundler (React / Vue / Vite / webpack):
npm install @sautipbx/voice-sdkimport { Phone } from '@sautipbx/voice-sdk';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:
- Your backend calls
POST /api/phone-tokens/mintwith your secret API key, passing the end-user's uuid. It gets back a token and aniceServersconfig (STUN/TURN for NAT traversal). - Your backend hands only the token and
iceServersto the browser. - The browser passes them to
phone.authenticate({ token, iceServers }).
If a token leaks it expires within minutes, works for a single extension, and can
be revoked instantly with POST /api/phone-tokens/revoke.
Quickstart
<script src="https://cdn.jsdelivr.net/npm/@sautipbx/[email protected]/dist/voice.iife.js"></script>
<script>
const phone = new VoiceSDK.Phone({ logLevel: 'info' });
// { token, iceServers } came from YOUR backend's mint call (see above).
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>API
new Phone(options?)
| Option | Default | Notes |
| ------------ | ------------------ | ----- |
| logLevel | 'none' | Silent by default. Set 'error' | 'info' | 'debug' to log to the browser console (prefixed [voice-sdk]); 'debug' also enables JsSIP wire tracing. |
| onLog | — | Custom log sink (level, ...args) => void. When set, emitted lines (still gated by logLevel) go here instead of the console — route them into your own UI/telemetry. |
| iceGatheringTimeout | 3000 | Fallback cap (ms) on ICE gathering. Normally the call is sent the instant a TURN relay candidate is gathered (sub-second), so this only bites if no relay ever arrives — avoiding the ~39.5s stall on UDP-restricted networks. 0 disables early-send and 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 FQDN | Override only for testing. |
| realm | production realm | SIP domain. |
Methods
authenticate(token | { token, iceServers })→Promise<void>— registers; resolves on success.call(destination, { customPayload? })→Call—destinationis a bare extension/number or full SIP URI.unregister()listDevices()→{ inputs, outputs }setInputDevice(id)/setOutputDevice(id)/setVolume(0..1)- Getters:
extension,account,isRegistered
Events: registered, unregistered, registrationFailed, connected, disconnected, incoming.
Capturing logs — route SDK logs into your own handler instead of the console:
const phone = new VoiceSDK.Phone({
logLevel: 'debug',
onLog: (level, ...args) => myLogStore.push({ level, args, at: Date.now() }),
});Other exports
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 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.
Call
answer()/reject()/hangup()hold()/unhold()mute()/unmute()sendDigit(tone)— DTMF- Getters:
direction,remoteIdentity,isOnHold,isMuted - Events:
ringing,accepted,ended,failed,hold,unhold,muted,unmuted.
Events: what you get here, and what lives on your backend
The SDK surfaces local call events (the ones above) — everything a phone UI
needs, observed directly in the browser. Platform-truth events that the browser
can't know — call cost, recording ready + URL, billing, and
bridged/far-leg state — are delivered to your backend via webhooks or a
backend /api/stream subscription, where your CDR/billing logic lives. That split
is deliberate: platform-truth events belong on your backend, where your own
records live.
Requirements & gotchas
- Secure context required. WebRTC mic capture only works over HTTPS (or
http://localhost). On a plainhttp://<LAN-IP>origin, registration can succeed but calls silently fail — the SDK throws a clear error when you try to call from an insecure context. - NAT / TURN. The default is a public STUN server, which is enough on
cooperative NATs but not for symmetric-NAT / mobile. Reliable traversal
needs TURN — the mint response's
iceServersincludes ephemeral, per-session TURN credentials, so pass it straight toauthenticate(). customPayloadis sent as theX-Sauti-Custom-PayloadINVITE header and is echoed back on every platform event and webhook for that call, so you can correlate a call with your own records automatically.
Local development
npm install
npm run typecheck # tsc --noEmit
npm run build # ESM + CJS + IIFE + .d.ts into dist/
npm run smoke # verify the build artifacts + pure logic