Skip to content

Scenario: click-to-call ​

For products whose users call out to contacts: a CRM's "Call" button, a sales dialer, a clinic calling patients back. The user clicks, your backend checks the call is allowed and creates it, the user's browser (or app) connects, and Calloop dials the contact over a phone number or WhatsApp. When the call ends, the outcome and duration land on the contact's record.

At the end you have: a Call button that rings the contact from your company's number, audio in the browser, and every call logged on the right contact automatically.

Steps at a glance ​

#StepRequired?API
1Get your keys and a lineRequiredfrom the Calloop operator
2Tenant and agentsRequiredPUT /v1/tenants/{t}, PUT …/agents/{ref}
3The line you call fromRequiredPUT …/channels/{ref}
4Token endpointRequiredyour backend
5The Call button: your backendRequiredPOST /v1/tenants/{t}/calls
6The Call button: the appRequiredSDK startOutbound
7Log the outcome on the contactRequiredcall.ended webhook
8Calls back inOptionala queue on the line
9RecordingOptionalline field
10Test before launchRequired—

Every request carries Authorization: Bearer <your API key>.

1. Get your keys and a line ​

Required. From the Calloop operator: your API key, signing key and webhook secret (the secret only if they set a webhook URL for you; otherwise your first PUT /v1/webhook with a URL returns it, once) (what every integration has), and either:

  • Phone calls: a SIP trunk to your carrier with outbound enabled, its trunk id (also listed by GET /v1/sip-trunks), and a number (DID) the carrier lets you show as caller ID.
  • WhatsApp calls: your WhatsApp Business number with calling enabled (its phone_number_id and an access token). WhatsApp only lets a business call someone who has given it permission to call; asking for that permission, under Meta's rules, is on your side.

2. Tenant and agents ​

Required.

http
PUT /v1/tenants/acme   { "name": "Acme Ltd" }

PUT /v1/tenants/acme/agents/rep-5    { "name": "Lina" }
PUT /v1/tenants/acme/agents/rep-6    { "name": "Sami" }

The agent ref is your user id: it is how you name who places each call.

3. The line you call from ​

Required. A phone number on the trunk, or a WhatsApp number:

http
PUT /v1/tenants/acme/channels/sales-line
{ "type": "SIP", "address": "+442079460123", "sip_trunk_id": 3 }

PUT /v1/tenants/acme/channels/wa-sales
{ "type": "WHATSAPP", "address": "+442079460000",
  "provider_account_id": "<phone_number_id>", "credentials": { "access_token": "<Meta token>" } }

A line with no queue and no owner is outbound-only: someone calling the number back is refused. To take those calls, see step 8. Every rep can call from a shared line; to give each rep a number of their own, make it their personal line (Personal numbers).

4. Token endpoint ​

Required. Your backend signs a short-lived token for the logged-in rep: tnt: "acme", sub = their agent ref (how).

5. The Call button: your backend ​

Required. The button calls your backend, which decides whether this rep may call this contact (permissions, do-not-call lists, consent) and then creates the call:

http
POST /v1/tenants/acme/calls
Idempotency-Key: 6f1c2a9e-…            (a new UUID per click: a retry never dials twice)
{ "channel_ref": "sales-line", "agent_ref": "rep-5",
  "customer": { "address": "+447700900123", "name": "Jane Doe" },
  "external_ref": "contact-991",
  "consumer_metadata": { "deal_id": 4410 } }

→ 201 { "call": { "callId": 88, "status": "INITIATED", … } }

Return call.callId to the rep's browser.

  • external_ref and consumer_metadata are yours: Calloop returns them on every event for this call, so the outcome finds its way back to the contact and the deal.
  • customer.address: a phone number in E.164 (+44…) for a phone line, or the person's WhatsApp number for a WhatsApp line.
  • Errors to handle:
    • 409: agent_busy (the rep is already on a call), channel_disabled, invalid_customer (the address doesn't fit the line), too_many_calls (your plan's limit of calls at once), unsupported_channel (the line can't call out).
    • 400: unknown channel_ref.
    • Any 402 is billing: your account or the tenant's can't pay for the call (account_suspended, outbound_blocked, insufficient_balance, tenant_suspended, tenant_outbound_blocked, tenant_insufficient_balance — Billing).
    • Any 403 is a call rule (destination_blocked, destination_not_allowed, emergency_not_supported, rate_too_high) or line_not_owned (a personal line that belongs to another agent).

6. The Call button: the app ​

Required. The rep's browser, connected with the JavaScript SDK, starts the call within 2 minutes (an intent not started by then is cancelled):

js
import { connect } from '@calloop/agent-sdk';

const agent = await connect({
  url: 'https://api.calloop.net',
  getToken: () => fetch('/api/calloop-token').then((r) => r.text()),
});

callButton.onclick = async () => {
  const { callId } = await fetch(`/api/contacts/${contactId}/call`, { method: 'POST' }).then((r) => r.json());
  const call = await agent.startOutbound(callId);     // asks for the microphone, then Calloop dials
  call.on('state', (state) => showStatus(state));     // 'dialing' → 'active' → 'ended'
  call.on('remoteStream', (s) => { audioEl.srcObject = s; });
  hangupButton.onclick = () => call.hangup();
};

No setAvailability is needed for outbound calls: a rep calls out whether or not they take queue calls.

7. Log the outcome on the contact ​

Required. Register your webhook (how); on call.ended, write the call to the contact named by externalRef:

js
// POST /calloop/events — after verifying the signature and de-duplicating on event_id
const { event_type, data } = req.body;
if (event_type === 'call.ended') {
  const c = data.call;
  await crm.logCall(c.externalRef, {
    rep: c.agentRef,
    outcome: c.terminationReason,          // see the table below
    talkSeconds: c.durations.call,
    at: c.createdAt,
    dealId: c.consumerMetadata?.deal_id,
  });
}
How the call wentterminationReasonterminatedBy
The contact answered, then someone hung upCOMPLETEDAGENT or CUSTOMER
The contact didn't answerNO_ANSWERCUSTOMER
The contact refused the call or was busyREJECTEDCUSTOMER
The rep hung up before the contact answeredCANCELLEDAGENT
The rep never started it (2 minutes)CANCELLEDSYSTEM
The carrier or WhatsApp couldn't place itstatus FAILED, e.g. PROVIDER_TRIGGER_FAILED or PROVIDER_ERROR; failureDetails says whyPROVIDER or SYSTEM

8. Calls back in ​

Optional. Contacts often call the number back. To ring your reps, give the line a queue (Contact center → queue) and set "inbound_queue_ref" on the line; reps who want those calls go AVAILABLE in the app. The lookup hook can show the contact's name from your CRM before it rings.

9. Recording ​

Optional. "recording_enabled": true on the line; fetch the file after recording.completed with GET /v1/calls/{id}/recording. Check your local rules on telling the contact.

10. Test before launch ​

Required.

  • Click, the contact's phone rings showing your number (or your WhatsApp business), audio both ways
  • No answer, refused, and the rep hanging up early each log the right outcome on the contact
  • Clicking twice fast, or a retried request, places one call (the Idempotency-Key)
  • A rep already on a call gets a clear "busy" from your backend (409 agent_busy)
  • call.ended signature verified and de-duplicated

Calloop — the call engine behind your product.