SIP trunks we dial through. The dial prefix is internal — the CRM never sees it.
| Label | LiveKit trunk | Prefix | Default DID | Default | Enabled |
|---|
See the clean CRM-facing number and the internal dialed string a trunk would produce.
LiveKit + CRM credentials and call knobs. Saving validates LiveKit creds with a live API call.
Outcomes from call records. Filter by date range and trunk.
A real two-way call from this browser — same path as a live agent. Grant mic access when prompted.
This page calls /click2call, joins the LiveKit room and publishes your mic — exactly like a real agent, before anyone is dialed.
/connect places the SIP call into the room. You hear carrier ringback (early media); the backend poller watches for answer.
On answer you get live two-way audio. Hang Up (or the customer dropping) finalizes the call and previews the exact CRM webhook.
The payload that would POST to the CRM on this outcome.
—
Everything a CRM developer needs to wire Click2Call — no need to ask anyone. Bookmark this.
Server-to-server POST /api/c2c/click2call with your X-api-key. You get back a call_id, a room name, the LiveKit ws_url, and a browser token.
The agent's browser joins the room with the token and publishes the mic, then calls /connect. We place the SIP call to the customer into that room.
Our backend detects answer / no-answer / hangup server-side and POSTs your webhook with the final status, duration, and SIP disconnect code.
Load the LiveKit browser SDK once in your agent UI:
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
Creates the room + mints the agent token. Does not dial yet. Send the customer number as exactly 10 digits — no agen_number, no trunk_id (we pick the trunk).
curl -X POST BASE/api/c2c/click2call \ -H "X-api-key: YOUR_INBOUND_KEY" \ -H "Content-Type: application/json" \ -d '{ "case_id": "abc123", "customer_number": "9530251797", "agent_id": "agent-42", "customer_crm_id": "cust-99" }'
Response:
{
"call_id": "c2c_ab12cd34",
"room": "c2c-ab12cd34",
"ws_url": "wss://...",
"token": "<agent JWT>",
"customer_number": "9530251797"
}Hand ws_url, token and call_id to the agent's browser. did (caller ID) is optional — we fall back to the trunk default and normalize to +91….
This is the one genuinely new piece on your side. Prerequisites:
Full, copy-complete snippet (res = the JSON returned by /click2call):
<!-- 1. Load the LiveKit browser SDK once (UMD global: LivekitClient) -->
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
<script>
const BASE = "BASE"; // this Click2Call service
let currentCallId = null;
async function preGrantMic() { // call once at agent login
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
s.getTracks().forEach(t => t.stop());
}
async function startCall(res) { // res = /click2call response
const room = new LivekitClient.Room();
const audioEl = Object.assign(document.createElement('audio'), { autoplay: true });
document.body.appendChild(audioEl);
// hear the customer
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, _p, p) => {
if (track.kind === 'audio' && p.identity.startsWith('sip')) track.attach(audioEl);
});
// customer dropped → end the call
room.on(LivekitClient.RoomEvent.ParticipantDisconnected, (p) => {
if (p.identity.startsWith('sip')) endCall(room, res.call_id);
});
// Report answer to the backend. REQUIRED in webhook mode — it's the answer
// signal (LiveKit emits no webhook for a sip.callStatus change). Harmless in
// polling mode (just a snappier UI hint). Always send it.
const report = (b) => fetch(`${BASE}/api/c2c/calls/${res.call_id}/status`,
{ method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(b), keepalive: true }).catch(() => {});
room.on(LivekitClient.RoomEvent.ParticipantAttributesChanged, (changed, p) => {
if (!p.identity.startsWith('sip') || !('sip.callStatus' in changed)) return;
if (p.attributes['sip.callStatus'] === 'active') report({ status: 'ANSWERED' });
});
// JOIN + PUBLISH MIC FIRST
await room.connect(res.ws_url, res.token);
await room.localParticipant.setMicrophoneEnabled(true,
{ echoCancellation: true, noiseSuppression: true, autoGainControl: true });
// THEN dial
await fetch(`${BASE}/api/c2c/calls/${res.call_id}/connect`, { method: 'POST' });
currentCallId = res.call_id;
return { room, callId: res.call_id };
}
// mute / unmute the agent mic
function setMuted(room, muted) {
const pub = room.localParticipant.getTrackPublication(LivekitClient.Track.Source.Microphone);
if (pub && pub.track) muted ? pub.track.mute() : pub.track.unmute();
}
async function endCall(room, callId) {
currentCallId = null;
await fetch(`${BASE}/api/c2c/calls/${callId}/hangup`, { method: 'POST' });
room.disconnect();
}
// flush a hangup if the tab closes mid-call
window.addEventListener('beforeunload', () => {
if (currentCallId) navigator.sendBeacon(`${BASE}/api/c2c/calls/${currentCallId}/hangup`);
});
</script>Browser calls this after it joined + published mic. Dials the customer, starts answer detection. Returns { "status": "ringing" }.
Ends the call (agent clicked, or tab closing via sendBeacon). Deletes the room, computes duration, fires your webhook. Idempotent.
Browser reports {status, ...} from LiveKit room events. The ANSWERED report is the answer signal in webhook mode (required); in polling mode it's just a snappier UI hint. Teardown stays backend-owned. See Answer detection.
Current call record for polling UIs (status, connected, duration, disconnect_reason, sip_disconnect_code).
How our backend learns a call was answered/ended. Set by us (the C2C operator) in Settings → Answer detection — not something the CRM configures. It changes one thing on your side: make sure the agent browser reports answer (the snippet above already does, via /status).
Backend samples list_participants. No public URL needed — ideal for local dev behind NAT. Doesn't capture the numeric SIP disconnect code (the leg is gone by the next sample).
LiveKit pushes events to /webhooks/livekit. Event-driven (no polling), and the participant_left event carries the final sip.disconnectCode. Needs LiveKit configured to reach our backend URL.
We POST your configured CRM webhook URL with header X-api-key: <your outbound key> on each terminal outcome. phone_to is always the clean 10-digit number (never the internal dial prefix).
{
"call_id": "c2c_ab12cd34",
"case_id": "abc123",
"customer_crm_id": "cust-99",
"phone_to": "9530251797",
"phone_from": "+919999999999",
"call_status": "ANSWERED", // ANSWERED | NOT_ANSWERED | FAILED
"duration_seconds": 90,
"agent_id": "agent-42"
}