Skip to content

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/events and have one sent again with POST /v1/events/{eventId}/redeliver (management-api.md).
  • Any other answer is a failed attempt, including a timeout (10 seconds) and a redirect: 3xx responses are not followed.
  • De-duplicate on event_id (also in the X-Calloop-Event-Id header). It is stable across retries.
  • Order is not guaranteed. Each call event carries the full current call, so apply them with "latest occurred_at wins". occurred_at is when the event was written, just after the change; to order two events for the same call exactly, compare data.call timestamps (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 with GET /v1/events meanwhile.

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])) < 300

Envelope ​

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:

FieldMeaning
callId, callUuidCalloop's id, and the same id shaped as a UUID for native call screens (CallKit, Android Telecom)
tenantId, channelId, queueId, agentId, ivrFlowIdCalloop's ids; use the *Ref fields (your refs) instead where there is one
tenantRef, channelRef, agentRefYour refs for the tenant, the line and the agent handling (or last offered) the call
agentNameThat agent's display name, else null
channel, channelAddressWHATSAPP or SIP, and the line's number
direction, statusINBOUND / OUTBOUND; INITIATED, RINGING, IN_PROGRESS, TERMINATED, FAILED
stateWhere an unfinished call is: IVR, QUEUE or ACTIVE; null before routing
mediaTopologyROOM (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, consumerMetadataYours: stored and returned, never interpreted
providerCallIdThe WhatsApp call id or SIP Call-ID, for matching provider logs
ringingAt, answeredAt, endedAt, createdAtISO 8601, UTC
terminationReason, terminatedByWhy and by whom it ended (Values)
durationsSeconds: ringing, call (talk), queue (entering the queue → answered or over)
failureDetailsWhat 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 ​

EventWhenExtra data
call.createdAn inbound call arrived, or an outbound intent was created—
call.queuedWaiting in a queue for an agent, or offered to every member of a RING_ALL queueoffered 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.assignedOffered to, or claimed for, a specific agentassignment_type (DIRECT, QUEUED, TRANSFERRED), offered_agent_ids (a list of that one agent's id)
call.ringingThe provider reports the customer's phone ringing (outbound)—
call.answeredInbound: an agent accepted. Outbound: the customer answered—
call.transferredAn 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.assignedfrom_agent_id, to_agent_id, to_queue_id
call.overflowedWaited max_wait_seconds and moved to the queue's overflow queuefrom_queue_id, to_queue_id
call.ivr.completedAn IVR session endedoutcome (transferred/hung_up/timeout/error), duration_seconds
call.endedTerminated or failed — see terminationReason, terminatedBy, durations—
recording.completedThe call's recording is stored; fetch it with GET /v1/calls/{id}/recordingrecording_id, duration_seconds
recording.failedThe 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.completedrecording_id, reason (a sentence for people to read, not a code)
agent.availability.changedAn 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_changedThe 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_changedYour 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 ACTIVEdata is { account_id, status, previous_status, reason, balance, currency }
billing.payment_succeededA top-up or auto top-up went through (once per payment), or you recorded a top-up for one of your tenants' accountsdata 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_failedA 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_issuedA 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_paidAn 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.answered and call.ended are sent at most once per call.
  • call.queued and call.assigned are 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.changed is 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_types in PUT /v1/webhook. The filter applies when an event is written: an event type you don't subscribe to is never stored, so GET /v1/events can'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 (status FAILED): your billing account couldn't place or take the call — suspended, outbound blocked, or not enough balance or credit for it (failureDetails says which); CREDIT_EXHAUSTED: the balance or credit ran out during the call (Billing). COMPLETED means 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 — is CANCELLED (within 5 s) or NO_ANSWER. An outbound call the customer refuses is BUSY when they were on another call (SIP 486, 600) and REJECTED when they declined it (603). On a personal line (management-api.md → Channels) a missed call is BUSY (the owner was on another call), NO_ANSWER (rang out, or no device to ring) or REJECTED (the owner declined); agentRef is the owner and channelRef the 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 as TIMEOUT / 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 on call.ended whoever ended the call; queue, from entering the queue to an agent answering (or to the end when nobody did), 0 for 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.

Calloop — the call engine behind your product.