Browser Calling with WebRTC
Mint a short-lived SIP token server-side with HS256, hand it to the Sautikit browser SDK, and place calls from any browser tab.
Sautikit browser calling works through a short-lived HS256 JWT called a SIP token. Your backend mints the token from POST /v1/sip/token using your API key and serves it to authenticated browser sessions. The browser SDK uses the token to open a WebSocket connection to the Sautikit SIP gateway and can then place or receive calls. Tokens expire after 5 minutes; the SDK refreshes them automatically by calling back to your server.
Your API key must never appear in browser code. A compromised API key could be used to place calls, claim numbers, or drain your wallet.
Instead, your server acts as a gatekeeper:
POST /v1/sip/token with your API key.If a token is leaked, it expires in 5 minutes and cannot be used to access the API or manage your workspace.
curl -s -X POST "https://api.sautikit.com/v1/sip/token" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .Response:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0YWxrc3RhY2stc2lwIiwic3ViIjoiMDE5MDAwMDAtMDAwMC03MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwiaWF0IjoxNzUxMDAwMDAwLCJleHAiOjE3NTEwMDAzMDB9.SIGNATURE",
"expires_at": "2026-06-27T12:05:00Z"
}The token is an HS256 JWT with these claims:
{
"iss": "sautikit-sip",
"sub": "<workspace_uuid>",
"iat": 1751000000,
"exp": 1751000300
}The sub claim is your workspace UUID. The gateway uses it to resolve the workspace and attribute billing. TTL is exactly 300 seconds (5 minutes).
Add a token endpoint to your server. Gate it behind your existing session/auth middleware so only your authenticated users can request tokens:
import express from "express";
const app = express();
// requireAuth is your existing session middleware
app.get("/api/sip-token", requireAuth, async (req, res) => {
try {
const resp = await fetch("https://api.sautikit.com/v1/sip/token", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
},
});
if (!resp.ok) {
const err = await resp.json();
console.error("Sautikit token mint failed:", err);
return res.status(502).json({ error: "token_mint_failed" });
}
const { token, expires_at } = await resp.json();
// Do not set long cache headers; each request must mint a fresh token
res.set("Cache-Control", "no-store");
res.json({ token, expires_at });
} catch (err) {
console.error("Token mint error:", err);
res.status(500).json({ error: "internal_error" });
}
});import os
import httpx
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
async def require_auth(request):
# Replace with your actual authentication logic
if not request.session.get("user_id"):
raise HTTPException(status_code=401, detail="not authenticated")
@app.get("/api/sip-token", dependencies=[Depends(require_auth)])
async def get_sip_token():
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://api.sautikit.com/v1/sip/token",
headers={"Authorization": f"Bearer {os.environ['SAUTIKIT_API_KEY']}"},
)
resp.raise_for_status()
data = resp.json()
return JSONResponse(
content={"token": data["token"], "expires_at": data["expires_at"]},
headers={"Cache-Control": "no-store"},
)func handleSIPToken(w http.ResponseWriter, r *http.Request) {
// Check your session/auth here
if !isAuthenticated(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://api.sautikit.com/v1/sip/token", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SAUTIKIT_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
http.Error(w, "token mint failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
io.Copy(w, resp.Body)
}Install the Sautikit browser SDK:
npm install @sautikit/browserInitialise the client in your browser code. Pass tokenUrl pointing at the server endpoint you created in Step 3:
import { SautikitClient } from "@sautikit/browser";
const client = new SautikitClient({
// Your server endpoint: the SDK calls this to get and refresh tokens
tokenUrl: "/api/sip-token",
// Optional: SDK will call this URL before every token refresh
onTokenRefresh: (expiresAt) => {
console.log(`Token refreshed, valid until ${expiresAt}`);
},
});
// Connect to the SIP gateway
await client.connect();
console.log("Connected to Sautikit SIP gateway");The SDK automatically refreshes the token approximately one minute before it expires by calling your tokenUrl again. You do not need to manage the refresh cycle manually.
// Place a call; `from` must be a number you own
const call = await client.call("+254722000001", {
from: "+254700000001",
});
// React to call events
call.on("ringing", () => updateUI("Ringing..."));
call.on("answered", () => updateUI("Connected"));
call.on("ended", (reason) => updateUI(`Call ended: ${reason}`));
call.on("failed", (reason) => updateUI(`Call failed: ${reason}`));
// Mute/unmute
document.getElementById("mute-btn").addEventListener("click", () => {
call.mute();
updateUI("Muted");
});
document.getElementById("unmute-btn").addEventListener("click", () => {
call.unmute();
updateUI("Unmuted");
});
// Hang up
document.getElementById("hangup-btn").addEventListener("click", async () => {
await call.hangup();
});If your number's voice_callback_url is configured to route inbound calls to a browser client, the SDK fires an incoming event:
client.on("incoming", (call) => {
console.log(`Incoming call from ${call.from}`);
// Show answer/reject UI
showIncomingCallUI({
from: call.from,
onAnswer: async () => {
await call.answer();
},
onReject: async () => {
await call.reject();
},
});
});When the user navigates away or logs out, disconnect the SDK to free the SIP registration:
window.addEventListener("beforeunload", () => {
client.disconnect();
});
// Or on logout
async function logout() {
await client.disconnect();
// ... your session teardown
}/api/sip-token endpoint without auth.SAUTIKIT_SIP_TOKEN_SIGNING_KEY in your backend configuration. All outstanding tokens are immediately invalidated.After connecting the browser SDK, check that your workspace shows a connected SIP registration. Place a test call from the browser and retrieve the call record:
curl -s "https://api.sautikit.com/v1/calls?direction=outbound&limit=1" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | \
jq '.[0] | {id, direction, status, from, to, duration_seconds}'The call record appears with direction: "outbound" and is debited from your KES wallet at hangup exactly as an API-originated call.