Trunks

SIP trunks we dial through. The dial prefix is internal — the CRM never sees it.

Configured trunks

LabelLiveKit trunkPrefixDefault DIDDefaultEnabled

Add trunk

Test normalize

See the clean CRM-facing number and the internal dialed string a trunk would produce.

Settings

LiveKit + CRM credentials and call knobs. Saving validates LiveKit creds with a live API call.

LiveKit

CRM webhook

Answer detection

Webhook needs LiveKit configured to POST /webhooks/livekit (and reads the real sip.disconnectCode). Polling needs no public URL. Changing this takes effect on next service restart.

Inbound calls

No DIDs means we answer nothing. This service is shared and LiveKit sends us an event for every room on the deployment, so we only pick up calls to a number listed here — another product pointing its own DID at the same LiveKit is untouched. Formats do not matter: +914412345678, 04412345678 and 4412345678 are the same number.

On an incoming call we ask the CRM who owns the caller and tell it to ring that person. Leave the CRM fields blank and calls still connect — they simply always ring Admin, because we cannot find out whose customer it is. That key is the CRM's, and is not the inbound key above.

Call knobs

Analytics

Outcomes from call records. Filter by date range and trunk.

Failures by SIP code

By day

Sandbox

A real two-way call from this browser — same path as a live agent. Grant mic access when prompted.

How a sandbox call works — 3 steps

1Join & publish mic

This page calls /click2call, joins the LiveKit room and publishes your mic — exactly like a real agent, before anyone is dialed.

2We dial the customer

/connect places the SIP call into the room. You hear carrier ringback (early media); the backend poller watches for answer.

3Talk, then hang up

On answer you get live two-way audio. Hang Up (or the customer dropping) finalizes the call and previews the exact CRM webhook.

To see a real sip_disconnect_code, let the call be declined / busy / ring out — don't click Hang Up while it's still ringing (that's a self-cancel, no carrier code).

Place a test call

Initiated Ringing Active Ended

What hit the trunk

CRM webhook preview

The payload that would POST to the CRM on this outcome.

Supervisor

Listen = silent monitor. Whisper = only the agent hears you. Barge = join the conversation (customer hears you too).

Live calls

auto-refreshes every 5s
CustomerAgentStatusDuration
Loading…

Session

not connected
Pick a live call above to join.

How whisper stays off the customer

Whisper allows only the agent to subscribe to your mic (LiveKit setTrackSubscriptionPermissions, set fail-closed before publishing) — the SFU refuses the customer's SIP leg.
Agent needs a headset for whisper, else your voice echoes to the customer via the agent's open mic (acoustic, not a routing leak).

Integration guide

Everything a CRM developer needs to wire Click2Call — no need to ask anyone. Bookmark this.

How it works — 3 steps

1Your backend starts a call

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.

2Agent browser joins, we dial

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.

3We report the outcome

Our backend detects answer / no-answer / hangup server-side and POSTs your webhook with the final status, duration, and SIP disconnect code.

The hard rule: the agent browser joins the room and publishes mic before the customer is dialed. This prevents dead-air and teardown races. Always call /connect only after the room is connected.

Setup & keys

Base URL
LiveKit ws_url
X-api-key (inbound)
— authorizes /click2call. The drop-in client sends it from the browser; keep it scoped to your agent app.
/connect, /hangup, /status and GET /calls/{id} are keyed by the opaque call_id — no key needed. Only /click2call uses the inbound key.

1 · Start a call (your backend)

POST/api/c2c/click2call

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.

2 · Agent browser snippet

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.

Full page example — where each piece goes in a real page
<!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.

Library source — c2c-client-webhook.js (reference only; loaded by the <script> above)
loading…
webhook reports ANSWERED (the answer signal); polling reports the sip.disconnectCode for analytics. Same window.C2C API. See Answer detection.

3 · Connect, hangup & poll

POST/api/c2c/calls/{call_id}/connect

Browser calls this after it joined + published mic. Dials the customer, starts answer detection. Returns { "status": "ringing" }.

POST/api/c2c/calls/{call_id}/hangup

Ends the call (agent clicked, or tab closing via sendBeacon). Deletes the room, computes duration, fires your webhook. Idempotent.

POST/api/c2c/calls/{call_id}/status

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.

GET/api/c2c/calls/{call_id}

Current call record for polling UIs (status, connected, duration, disconnect_reason, sip_disconnect_code).

HTTP status codes

What each endpoint returns, so you can branch on the code. Error bodies are JSON: { "detail": "…" }.

CodeWhereMeaning / how to handle
200allSuccess.
401/click2callMissing or wrong X-api-key. Fix the key — don't retry as-is.
422/click2callcustomer_number isn't exactly 10 digits, or malformed JSON body. Validation error — don't retry without fixing input.
409/click2callThat call_id already exists (in flight). Use a fresh/unique id.
409/connectCall 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/connectSIP 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/click2callNo enabled trunk configured on our side. Operational — contact us.
500anyServer misconfig (e.g. settings not initialized). Operational — contact us.
/hangup is idempotent — it returns 200 even if the call already ended, so the beforeunload beacon and an explicit hang-up are both safe. Safe to retry: 5xx. Don't retry unchanged: 401 / 404 / 409 / 422.

Answer detection (polling vs webhook)

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).

DEV Polling

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).

PROD Webhook

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.

Same contract either way: teardown (answered / not-answered / hangup / agent-drop grace) is owned by our backend, and you receive the identical CRM webhook. Pick the matching client: c2c-client-webhook.js reports ANSWERED (the answer signal — required, since LiveKit has no event for a sip.callStatus change); c2c-client-polling.js instead reports just the sip.disconnectCode for our internal analytics (the poller can't capture it). Both are drop-in window.C2C.

Webhook you receive

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
}
Agent availability is driven by this webhook — flip the agent BUSY→AVAILABLE when a terminal call_status arrives. It fires reliably even if the agent's tab closed mid-call (our backend owns finalization).

Supervisor — listen, whisper & barge

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).

GET/api/c2c/calls/live

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.

POST/api/c2c/calls/{call_id}/supervisor

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>
For whisper, the agent must use a headset — on open speakers the supervisor's voice is picked up by the agent mic and relayed to the customer (acoustic echo, not a routing leak). Test it live in the Supervisor tab.

Go-live checklist

  • Point your click2call action at /api/c2c/click2call with the inbound X-api-key; send customer_number as 10 digits. Drop agen_number, user_id, trunk_id, number.
  • Embed the browser snippet in the agent UI and call preGrantMic() at login.
  • Set your webhook receiver to accept our payload shape and verify the outbound X-api-key.
  • Flip agent BUSY→AVAILABLE from the webhook's terminal call_status.
  • Dry-run the whole flow in the Sandbox tab before cutting over.