Appearance
Consumer events (v1)
Calloop tells a consumer's backend what happened by POSTing events to the webhook url the consumer set with PUT /v1/webhook. This is how a product keeps its own records — call history in its CRM, a "call" bubble in its chat timeline, billing, reporting — without reading Calloop's database.
Delivery
- Written after the change, then delivered at least once. An event is written to an outbox right after the change that caused it has been committed, as a separate step — not in the same transaction. A failed write is retried for a few seconds. Once written, the event is retried with backoff (5s, 15s, 1m, 5m, 15m, 30m, then hourly) until your endpoint answers
2xx, and given up after the 12th failed attempt. - Missed some? Read them back with
GET /v1/eventsand have one sent again withPOST /v1/events/{eventId}/redeliver(management-api.md). - Any other answer is a failed attempt, including a timeout (10 seconds) and a redirect:
3xxresponses are not followed. - De-duplicate on
event_id(also in theX-Calloop-Event-Idheader). It is stable across retries. - Order is not guaranteed. Each call event carries the full current call, so apply them with "latest
occurred_atwins".occurred_atis when the event was written, just after the change; to order two events for the same call exactly, comparedata.calltimestamps (answeredAt,endedAt) too. - Respond quickly and do your processing afterwards.
- While the consumer has no webhook
url, or is suspended, events are kept and delivered once it has one again, for as long as Calloop keeps events (30 days by default): an older one is removed undelivered. They can be read withGET /v1/eventsmeanwhile.
Verifying requests
POST <your webhook url>
Content-Type: application/json
User-Agent: Calloop-Webhooks/1
X-Calloop-Event: call.ended
X-Calloop-Event-Id: 7c0b1e0c-8a45-4c1b-9d4c-0f3a6a2f9b11
X-Calloop-Signature: t=1790000000,v1=5d41402abc4b2a76b9719d911017c592...X-Calloop-Signature is sent only when the consumer has a webhook secret. v1 is the hex HMAC-SHA256 of "<t>.<raw request body>" keyed with that secret. Recompute it over the raw bytes, compare in constant time, and reject requests whose t is more than 5 minutes old.
js
const m = /t=(\d+),v1=([a-f0-9]+)/.exec(req.headers['x-calloop-signature'] ?? '');
const expected = m && crypto.createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest('hex');
const ok = !!m && m[2].length === expected.length
&& crypto.timingSafeEqual(Buffer.from(m[2]), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - Number(m[1])) < 300;php
$body = file_get_contents('php://input'); // the raw bytes, before any JSON decoding
preg_match('/t=(\d+),v1=([a-f0-9]+)/', $_SERVER['HTTP_X_CALLOOP_SIGNATURE'] ?? '', $m);
$expected = hash_hmac('sha256', ($m[1] ?? '') . '.' . $body, $secret);
$ok = $m && hash_equals($expected, $m[2]) && abs(time() - (int) $m[1]) < 300;python
import hashlib, hmac, re, time
m = re.match(r"t=(\d+),v1=([a-f0-9]+)", request.headers.get("X-Calloop-Signature", ""))
expected = hmac.new(secret.encode(), f"{m[1]}.".encode() + raw_body, hashlib.sha256).hexdigest() if m else ""
ok = bool(m) and hmac.compare_digest(expected, m[2]) and abs(time.time() - int(m[1])) < 300Envelope
json
{
"event_id": "7c0b1e0c-8a45-4c1b-9d4c-0f3a6a2f9b11",
"event_type": "call.ended",
"api_version": "2026-09-25",
"occurred_at": "2026-09-25T10:05:12.000Z",
"tenant_ref": "biz-123",
"data": {
"call": {
"callId": 42,
"callUuid": "00000000-0000-0000-0000-000000000042",
"tenantId": 1,
"tenantRef": "biz-123",
"channel": "WHATSAPP",
"channelId": 1,
"channelAddress": "+442079460000",
"channelRef": "support-line",
"queueId": 1,
"direction": "INBOUND",
"status": "TERMINATED",
"state": null,
"mediaTopology": "ROOM",
"customer": { "address": "+447700900123", "addressType": "E164", "name": "Test Customer" },
"agentId": 7,
"agentRef": "user-7",
"agentName": "Agent One",
"externalRef": "crm-call-42",
"providerCallId": "wacid.HBgL...",
"ivrFlowId": null,
"ringingAt": "2026-09-25T10:00:00.000Z",
"answeredAt": "2026-09-25T10:00:06.000Z",
"endedAt": "2026-09-25T10:05:12.000Z",
"terminationReason": "COMPLETED",
"terminatedBy": "CUSTOMER",
"durations": { "ringing": 6, "call": 306, "queue": 4 },
"failureDetails": null,
"consumerMetadata": { "crm_contact_id": 991 },
"createdAt": "2026-09-25T10:00:00.000Z"
}
}
}Every call.* and recording.* event has data.call (the same view the Management API returns), plus the extra fields below.
The call view's fields:
| Field | Meaning |
|---|---|
callId, callUuid | Calloop's id, and the same id shaped as a UUID for native call screens (CallKit, Android Telecom) |
tenantId, channelId, queueId, agentId, ivrFlowId | Calloop's ids; use the *Ref fields (your refs) instead where there is one |
tenantRef, channelRef, agentRef | Your refs for the tenant, the line and the agent handling (or last offered) the call |
agentName | That agent's display name, else null |
channel, channelAddress | WHATSAPP or SIP, and the line's number |
direction, status | INBOUND / OUTBOUND; INITIATED, RINGING, IN_PROGRESS, TERMINATED, FAILED |
state | Where an unfinished call is: IVR, QUEUE or ACTIVE; null before routing |
mediaTopology | ROOM (a media-server room: queues, transfers, IVR, whisper and barge) or DIRECT (a personal line's one-to-one call; it can still be listened to and recorded) |
customer | { address, addressType (E164 / WHATSAPP_USER / SIP_URI), name } |
externalRef, consumerMetadata | Yours: stored and returned, never interpreted |
providerCallId | The WhatsApp call id or SIP Call-ID, for matching provider logs |
ringingAt, answeredAt, endedAt, createdAt | ISO 8601, UTC |
terminationReason, terminatedBy | Why and by whom it ended (Values) |
durations | Seconds: ringing, call (talk), queue (entering the queue → answered or over) |
failureDetails | What the provider said when it ended the call: { errors: [{ code, title, details, source }], … }, else null. On every failed call, and on a call that ended without failing when the provider gave a reason (a SIP carrier's 480 after ringing: NO_ANSWER, with the carrier's reply). A SIP trunk's reply: code its status, title its reason phrase, details its Reason / Warning headers (e.g. Q.850;cause=16), source SIP |
agent.availability.changed has no data.call; its top-level tenant_ref names the agent's tenant. sip_trunk.status_changed and the billing.* events have neither data.call nor a top-level tenant_ref: they are about your own account. A billing.* event about the account one of your tenants has with you names that tenant in data.tenant_ref.
Event types
| Event | When | Extra data |
|---|---|---|
call.created | An inbound call arrived, or an outbound intent was created | — |
call.queued | Waiting in a queue for an agent, or offered to every member of a RING_ALL queue | offered to a RING_ALL queue: assignment_type (QUEUED), offered_agent_ids (may be [] when the call reaches the queue and no member is free); waiting with nobody to offer it to: — |
call.assigned | Offered to, or claimed for, a specific agent | assignment_type (DIRECT, QUEUED, TRANSFERRED), offered_agent_ids (a list of that one agent's id) |
call.ringing | The provider reports the customer's phone ringing (outbound) | — |
call.answered | Inbound: an agent accepted. Outbound: the customer answered | — |
call.transferred | An agent or supervisor moved the call to another agent, or to a free agent of a queue (to_queue_id set), or assigned a waiting call to an agent (from_agent_id null). An IVR sending a call to a queue is not a transfer: that sends call.ivr.completed, then call.queued or call.assigned | from_agent_id, to_agent_id, to_queue_id |
call.overflowed | Waited max_wait_seconds and moved to the queue's overflow queue | from_queue_id, to_queue_id |
call.ivr.completed | An IVR session ended | outcome (transferred/hung_up/timeout/error), duration_seconds |
call.ended | Terminated or failed — see terminationReason, terminatedBy, durations | — |
recording.completed | The call's recording is stored; fetch it with GET /v1/calls/{id}/recording | recording_id, duration_seconds |
recording.failed | The call won't have a recording: the tenant's storage quota was used up, it couldn't start, it started just before an upgrade of the media server and won't be delivered, or it never arrived from the media server (24 h after the call ended). A recording that arrives after that is still stored and sends recording.completed | recording_id, reason (a sentence for people to read, not a code) |
agent.availability.changed | An agent's reported availability changed: ON_CALL while a call holds them, else their shift (AVAILABLE, OFFLINE) | data is { agent_ref, agent_id, availability, reason? }; reason: auto_offline_missed_calls when the auto-offline policy took the agent offline |
sip_trunk.status_changed | The Calloop operator decided on one of your own SIP trunks: approved (PENDING → ACTIVE), sent back (PENDING → DRAFT, with note: what to change), suspended (→ SUSPENDED), suspension lifted (→ DISABLED) | data is { trunk_id, name, status, previous_status, note } |
billing.status_changed | Your billing account's status changed (or a tenant's account with you: data.tenant_ref names it): OUTBOUND_BLOCKED (no balance, credit limit, overdue invoice), SUSPENDED, back to ACTIVE | data is { account_id, status, previous_status, reason, balance, currency } |
billing.payment_succeeded | A top-up or auto top-up went through (once per payment), or you recorded a top-up for one of your tenants' accounts | data is { account_id, payment_id, kind, amount, tax, currency, balance } — amount is what the balance got, tax was charged on top. A top-up you recorded for a tenant: { account_id, tenant_ref, payment_id: null, kind: "TOPUP", amount, currency, balance, reference } — no tax, and reference is the one you gave (or null) |
billing.payment_failed | A payment failed — an auto top-up's card declined, or it couldn't be charged without a billing address for its tax (once per payment) | data is { account_id, payment_id, kind, amount, currency, reason } |
billing.invoice_issued | A monthly invoice was issued (once) | data is { account_id, invoice_id, number, total, tax, amount_due, currency, period, due_at, hosted_url } — total before tax, amount_due with it |
billing.invoice_paid | An invoice was paid — charged to your card, or marked paid for a bank transfer (once) | data is { account_id, invoice_id, number, total, currency, paid_by } |
call.created,call.answeredandcall.endedare sent at most once per call.call.queuedandcall.assignedare sent again every time the call is re-offered: a decline or ring timeout passing it on, an overflow, a transfer, or a live call going back to its queue.agent.availability.changedis sent only when the availability actually changes — not for a client resync or the same value set again.- You can receive only some event types:
event_typesinPUT /v1/webhook. The filter applies when an event is written: an event type you don't subscribe to is never stored, soGET /v1/eventscan't return it later either.
Values
terminationReason:COMPLETED,CANCELLED,REJECTED,BUSY,NO_ANSWER,TIMEOUT,AGENT_DISCONNECTED,AGENT_MEDIA_NOT_READY,SYSTEM_ERROR,NETWORK_ERROR,PROVIDER_ERROR,PROVIDER_TRIGGER_FAILED,SERVICE_MAINTENANCE,CUSTOMER_NETWORK_LOSS,IVR_AGENT_NO_ANSWER,BILLING_REFUSED,CREDIT_EXHAUSTED.BILLING_REFUSED(statusFAILED): your billing account couldn't place or take the call — suspended, outbound blocked, or not enough balance or credit for it (failureDetailssays which);CREDIT_EXHAUSTED: the balance or credit ran out during the call (Billing).COMPLETEDmeans the customer talked to an agent (or the IVR ended the call itself). A customer who hangs up before any agent answered — including while waiting after an IVR — isCANCELLED(within 5 s) orNO_ANSWER. An outbound call the customer refuses isBUSYwhen they were on another call (SIP486,600) andREJECTEDwhen they declined it (603). On a personal line (management-api.md→ Channels) a missed call isBUSY(the owner was on another call),NO_ANSWER(rang out, or no device to ring) orREJECTED(the owner declined);agentRefis the owner andchannelRefthe line. A live call transferred to an agent who doesn't accept it in time goes back to its queue (inbound) — it keeps ringing agents — or ends asTIMEOUT/SYSTEM(outbound calls, calls without a queue).terminatedBy:AGENT(an agent or supervisor),CONSUMER(your backend,POST /v1/calls/{id}/terminate),CUSTOMER,PROVIDER,SYSTEM.durations(seconds):ringing;call, from the answer (an agent's, or the IVR's when an IVR answered) to the end — the provider's figure when it has reported it, otherwise Calloop's own, so it is set oncall.endedwhoever ended the call;queue, from entering the queue to an agent answering (or to the end when nobody did),0for a call that never waited in a queue.status:INITIATED,RINGING,IN_PROGRESS,TERMINATED,FAILED.customer.addressType:E164,WHATSAPP_USER(a WhatsApp user reachable without a phone number),SIP_URI.