Appearance
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
| # | Step | Required? | API |
|---|---|---|---|
| 1 | Get your keys and a line | Required | from the Calloop operator |
| 2 | Tenant and agents | Required | PUT /v1/tenants/{t}, PUT …/agents/{ref} |
| 3 | The line you call from | Required | PUT …/channels/{ref} |
| 4 | Token endpoint | Required | your backend |
| 5 | The Call button: your backend | Required | POST /v1/tenants/{t}/calls |
| 6 | The Call button: the app | Required | SDK startOutbound |
| 7 | Log the outcome on the contact | Required | call.ended webhook |
| 8 | Calls back in | Optional | a queue on the line |
| 9 | Recording | Optional | line field |
| 10 | Test before launch | Required | — |
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_idand 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_refandconsumer_metadataare 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: unknownchannel_ref.- Any
402is 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
403is a call rule (destination_blocked,destination_not_allowed,emergency_not_supported,rate_too_high) orline_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 went | terminationReason | terminatedBy |
|---|---|---|
| The contact answered, then someone hung up | COMPLETED | AGENT or CUSTOMER |
| The contact didn't answer | NO_ANSWER | CUSTOMER |
| The contact refused the call or was busy | REJECTED | CUSTOMER |
| The rep hung up before the contact answered | CANCELLED | AGENT |
| The rep never started it (2 minutes) | CANCELLED | SYSTEM |
| The carrier or WhatsApp couldn't place it | status FAILED, e.g. PROVIDER_TRIGGER_FAILED or PROVIDER_ERROR; failureDetails says why | PROVIDER 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.endedsignature verified and de-duplicated