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.

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.

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)
— your CRM backend sends this on /click2call. Keep it secret (server-side only).
Only /click2call needs the secret (server-to-server). The browser endpoints (/connect, /hangup, /status, GET /calls/{id}) are keyed by the opaque call_id and don't carry the secret — so it never ships to the browser.

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>

1 · Start a call (your backend)

POST/api/c2c/click2call

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

2 · Agent browser snippet

This is the one genuinely new piece on your side. Prerequisites:

  • Load the LiveKit browser SDK (below) — it exposes the global LivekitClient.
  • Served over HTTPS (or localhost) — browsers only grant mic access on a secure origin.
  • Call preGrantMic() once at agent login; keep the latest call_id in currentCallId for the unload beacon.

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>
Mute uses setMuted(room, true/false) above. Prefer not to maintain this? Drop in our ready-made /js/c2c-client.js (window C2C) and call C2C.startCall() / C2C.endCall() instead.

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

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. The only requirement on your side is the browser ANSWERED report — in webhook mode it's the answer signal (LiveKit has no event for a sip.callStatus change). Use our /js/c2c-client.js and it's handled for you.

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": "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"
}
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).

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.