Browser SDK
@sautipbx/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
Section titled “Install”Script tag. No build step; exposes a VoiceSDK browser global:
<script src="https://cdn.jsdelivr.net/npm/@sautipbx/voice-sdk@0.2.0/dist/voice.iife.js"></script>Bundler (React / Vue / Vite / webpack):
npm install @sautipbx/voice-sdkimport { Phone } from "@sautipbx/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)
Section titled “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 /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 herecurl https://api.sautipbx.com/api/phone-tokens/mint \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "uuid": "the-end-user-uuid" }'{ "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.
Quickstart
Section titled “Quickstart”A complete phone. Outbound and inbound. In one script tag:
<script src="https://cdn.jsdelivr.net/npm/@sautipbx/voice-sdk@0.2.0/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>The Phone object
Section titled “The Phone object”Create one Phone, authenticate it once, then place and receive calls on it.
Constructor options
Section titled “Constructor options”| 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. |
iceGatheringTimeout |
3000 |
Fallback cap (ms) on ICE gathering. Normally the call sends the instant a TURN relay candidate appears. 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
Section titled “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 or 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
Section titled “Events”registered, unregistered, registrationFailed, connected, disconnected, incoming. The
incoming event hands you a Call.
The Call object
Section titled “The Call object”Returned by phone.call(...) (outbound) and by the incoming event (inbound). One Call is one
call leg.
Methods
Section titled “Methods”answer()/reject(). Answer or decline an incoming call (rejectsends 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
Section titled “Events”ringing, accepted, ended, failed, hold, unhold, muted, unmuted. On ended /
failed the SIP status line and cause are surfaced so you can show why a call dropped.
Other exports
Section titled “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".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 do not passiceServers.
Events: local here, platform truth on your backend
Section titled “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 cannot know. Call cost, recording ready and its 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
Section titled “Secure context is required”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
Section titled “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 is not 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. |
| 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. |