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.
—
Listen = silent monitor. Whisper = only the agent hears you. Barge = join the conversation (customer hears you too).
| Customer | Agent | Status | Duration | |
|---|---|---|---|---|
| Loading… | ||||
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.
Creates the room + mints the agent token — does not dial yet. customer_number = exactly 10 digits. did, customer_crm_id and call_id are optional. Pass your own call_id and we echo it back in the response and every webhook (else we generate one); omit did to use the trunk default.
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", "did": "919999999999", "customer_crm_id": "cust-99", "call_id": "crm-uuid-here" }'
Response:
{
"call_id": "crm-uuid-here", // echoes what you sent (or a generated c2c_… id)
"room": "c2c-ab12cd34",
"ws_url": "wss://...",
"token": "<agent JWT>",
"customer_number": "9530251797"
}Hand ws_url, token, call_id to the agent's browser.
Two things: load our client, then call it from your agent UI. The client is a hosted library — you load it by URL, you don't paste its source.
A) Load on your agent page (must be HTTPS for mic access):
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script> <script>window.C2C_BASE = "BASE";</script> <script src="BASE/js/c2c-client-webhook.js"></script>
B) Use it from your code (this is what you write):
// once, at agent login
await C2C.preGrantMic();
// when your agent clicks "Call" on a case:
const session = await C2C.startCall({
caseId: "abc123",
customerNumber: "9530251797", // 10 digits
agentId: "agent-42",
callId: "crm-uuid-here", // your id — echoed back in response + webhook (optional)
apiKey: "YOUR_INBOUND_KEY", // authorizes /click2call
onCustomerLeft: () => C2C.endCall(session), // customer hung up
});
// wire your existing buttons:
muteButton.onclick = () => C2C.toggleMute(session); // mute / unmute
hangupButton.onclick = () => C2C.endCall(session); // hang up
// flush a hangup if the tab closes mid-call
window.addEventListener("beforeunload", () => {
if (session) navigator.sendBeacon(`BASE/api/c2c/calls/${session.callId}/hangup`);
});That's the whole integration. C2C.startCall() creates the call, joins the room, publishes the mic and dials — all for you.
<!DOCTYPE html> <html> <head> <!-- (1) LOAD once — in your base template / page head --> <script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script> <script>window.C2C_BASE = "BASE";</script> <script src="BASE/js/c2c-client-webhook.js"></script> </head> <body> <!-- your existing agent UI --> <button id="callBtn">Call</button> <button id="muteBtn" disabled>Mute</button> <button id="hangupBtn" disabled>Hang up</button> <!-- (2) USE — your integration code --> <script> const INBOUND_KEY = "YOUR_INBOUND_KEY"; let session = null; // the current call (one at a time) C2C.preGrantMic(); // once, at agent login callBtn.onclick = async () => { session = await C2C.startCall({ caseId: "abc123", // from your CRM record customerNumber: "9530251797", // 10 digits, from the record agentId: "agent-42", callId: "crm-uuid-here", // your id — echoed in response + webhook apiKey: INBOUND_KEY, onCustomerLeft: () => cleanup(), }); muteBtn.disabled = hangupBtn.disabled = false; }; muteBtn.onclick = () => { muteBtn.textContent = C2C.toggleMute(session) ? "Unmute" : "Mute"; }; hangupBtn.onclick = async () => { await C2C.endCall(session); cleanup(); }; function cleanup() { session = null; muteBtn.disabled = hangupBtn.disabled = true; muteBtn.textContent = "Mute"; } window.addEventListener("beforeunload", () => { if (session) navigator.sendBeacon(`BASE/api/c2c/calls/${session.callId}/hangup`); }); </script> </body> </html>
Load tags → base layout (load once). Use code → your agent JS, wired to your real buttons. React/Vue: tags in index.html, keep session in a ref/state.
loading…
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).
What each endpoint returns, so you can branch on the code. Error bodies are JSON: { "detail": "…" }.
| Code | Where | Meaning / how to handle |
|---|---|---|
| 200 | all | Success. |
| 401 | /click2call | Missing or wrong X-api-key. Fix the key — don't retry as-is. |
| 422 | /click2call | customer_number isn't exactly 10 digits, or malformed JSON body. Validation error — don't retry without fixing input. |
| 409 | /click2call | That call_id already exists (in flight). Use a fresh/unique id. |
| 409 | /connect | Call already connected or ended — you called /connect twice. Ignore. |
| 404 | /connect, /hangup, /status, GET /calls/{id} | Unknown call_id. Check the id from the /click2call response. |
| 502 | /connect | SIP dial failed at the trunk (bad number, trunk/route issue). The call is finalized as FAILED and a webhook fires. Surface to the agent; a retry may help for transient trunk errors. |
| 503 | /click2call | No enabled trunk configured on our side. Operational — contact us. |
| 500 | any | Server misconfig (e.g. settings not initialized). Operational — contact us. |
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": "crm-uuid-here", // the id you sent to /click2call
"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",
"recording_url": "https://…/SCL_xxx.wav" // trunk recording, or null
}Let a supervisor monitor or coach a live call. Listen = hears both, silent. Whisper = coaches the agent only — the customer never hears the supervisor (LiveKit track subscription permissions, set fail-closed before the mic publishes). Barge = joins the conversation (customer hears you too).
List ongoing calls to show in a supervisor console (poll every ~5s). Returns { calls: [{ call_id, customer_number, agent_id, status, connected, duration_seconds }] } — only rooms that are genuinely live.
Body { "mode": "listen"|"whisper"|"barge", "supervisor_id": "..." }, auth via X-api-key. Returns a room token + the agent/customer identities. Errors: 404 unknown, 409 not live, 422 bad mode.
Load the client and wire a "Monitor" button on your live-call UI:
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
<script src="BASE/js/supervisor-client.js"></script>
<script>
await Supervisor.join({ callId, mode: "listen", apiKey: INBOUND_KEY }); // monitor silently
await Supervisor.preGrantMic();
await Supervisor.setMode("whisper"); // coach the agent (customer can't hear)
await Supervisor.setMode("barge"); // join the call (everyone hears you)
await Supervisor.setMode("listen"); // back to silent
Supervisor.leave();
</script>