How to add a click-to-call button to your web app

A working browser softphone in about twenty minutes — mint a short-lived phone token on your server, load the SDK, and place a real phone call from a button in your app.

Your users are looking at a customer record, a delivery, a support ticket. There is a phone number on the screen. Today they copy it into a handset. This post replaces that with a button.

By the end you will have a real call — from a browser tab, over the public phone network — placed by @sautipbx/voice-sdk. No plugin, no desktop app, no PBX to run.

What you need

  • A SautiPBX account and a secret API key from the dashboard.
  • Somewhere to run about thirty lines of server code. Any language works; the examples are Node.
  • HTTPS, or http://localhost while you develop. WebRTC will not open a microphone on a plain http:// origin, and this is the single most common reason a first attempt fails.

The one rule: your API key never reaches the browser

Everything else here is detail. The secret key stays on your server. The browser is handed a phone token instead — short-lived, tied to one extension, and revocable at any moment.

The flow is three steps:

  1. Your backend calls POST /phone-tokens/mint with your secret key.
  2. It gets back a token and an iceServers config, and passes only those to the browser.
  3. The browser calls phone.authenticate({ token, iceServers }).

If a token leaks it expires in minutes, works for one extension, and you can kill it immediately. A leaked API key is a different kind of day.

Step 1 — create an end-user

An end-user is the identity behind a softphone. Each one gets its own extension. Create one per person who will make calls.

Terminal window
curl https://api.sautipbx.com/api/users/create \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "email": "agent1@yourapp.com", "display_name": "Agent One" }'
{ "uuid": "8f14e45f-...", "extension_number": "2001" }

Store that uuid against your own user record. You will need it every time you mint a token.

Step 2 — mint a token on your server

One endpoint. It authenticates your own user however your app already does, looks up their SautiPBX uuid, and asks for a token.

// server.js — the secret key lives here and nowhere else
app.post("/api/phone-token", async (req, res) => {
const user = await requireLogin(req); // your own auth, not ours
const upstream = await fetch("https://api.sautipbx.com/api/phone-tokens/mint", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAUTI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ uuid: user.sautiEndUserUuid }),
});
const { token, iceServers } = await upstream.json();
// Hand the browser these two fields ONLY.
res.json({ token, iceServers });
});

Two things to notice. The mint response keys line up with what the SDK expects, so there is no field remapping. And you are passing iceServers through rather than dropping it — that config carries the TURN relay, which is what makes calls work on mobile data and behind office NAT.

Step 3 — load the SDK and register

One script tag is a complete phone. JsSIP is bundled, so there is nothing else to load.

<script src="https://cdn.jsdelivr.net/npm/@sautipbx/voice-sdk@0.2.0/dist/voice.iife.js"></script>

Pin the exact version while you are testing. CDNs cache hard.

const phone = new VoiceSDK.Phone({ logLevel: "info" });
async function connect() {
const { token, iceServers } = await fetch("/api/phone-token").then((r) => r.json());
await phone.authenticate({ token, iceServers });
}

authenticate resolves when the phone is registered and rejects if it is not. Do not swallow that rejection — a rejected register is almost always an expired token, and you want to see it.

Step 4 — the button

<button id="call" data-number="+254711111111">Call customer</button>
<button id="hangup" hidden>Hang up</button>
const callBtn = document.getElementById("call");
const hangupBtn = document.getElementById("hangup");
let active = null;
callBtn.addEventListener("click", () => {
active = phone.call(callBtn.dataset.number, {
customPayload: JSON.stringify({ ticketId: "T-4821" }),
});
active.on("ringing", () => (callBtn.textContent = "Ringing…"));
active.on("accepted", () => (callBtn.textContent = "Connected"));
active.on("ended", reset);
active.on("failed", reset);
hangupBtn.hidden = false;
});
hangupBtn.addEventListener("click", () => active?.hangup());
function reset(reason) {
console.log(VoiceSDK.describeFailure(reason));
callBtn.textContent = "Call customer";
hangupBtn.hidden = true;
active = null;
}

That is the whole thing. phone.call() returns a Call, the Call emits ringing, accepted, ended and failed, and describeFailure() turns a failure reason into one readable line like 402 Insufficient balance · cause=Rejected.

The customPayload trick

That customPayload above is worth more than it looks. It rides the call as a header, and it comes back to your backend in the call webhook and the event stream. So the ticket the agent was looking at when they clicked is attached to the call record, without you correlating anything by timestamp afterwards.

Keep it under 500 characters and put no newlines in it.

Taking incoming calls too

The same phone answers. Add one listener:

phone.on("incoming", (call) => {
if (!confirm(`Call from ${call.remoteIdentity}. Answer?`)) return call.reject();
call.answer();
});

reject() sends a SIP 486 Busy. answer() connects the audio.

What we skipped, and when it bites

Token expiry. Tokens are short-lived on purpose. A tab left open overnight will fail to call in the morning. Re-mint on registrationFailed, or check VoiceSDK.isExpired(token) before dialling and fetch a fresh one.

Device selection. phone.listDevices() gives you inputs and outputs, and setInputDevice(id) / setOutputDevice(id) switch them. Every real deployment ends up needing this, because someone always has three microphones.

Mute and hold. call.mute(), call.unmute(), call.hold(), call.unhold(). They emit matching events so your UI can follow state rather than guess it.

Call cost and recordings. The browser deliberately never sees these. Cost, billing and recording-ready events go to your backend over webhooks or the event stream, so one user’s call data never lands in another user’s tab.

When it does not work

What you see What it usually is
authenticate rejects at once Expired or malformed token. Mint a fresh one.
Registers, but the call has no audio Insecure origin. Serve over HTTPS.
Long stall before connecting No TURN relay reached. Pass iceServers from the mint response.
Nothing in the console logLevel defaults to 'none'. Set 'info'.

Next

The Browser SDK reference has the full Phone and Call API, and the phone-tokens endpoint covers ttl_seconds, max_concurrent and revocation.

The SDK is at 0.2.x and still moving before 1.0, so pin an exact version in production.