Calls
Your organization's calls: every call it placed or took, with its outcome once one has been filed. Two routes:
GET /api/public/v1/calls— a page of calls, newest first, or a change feed withupdated_sinceGET /api/public/v1/calls/{id}— one call, by the id a webhook or the list gave you
Use them beside the webhooks, not instead of them. call.completed and call_outcome.submitted tell you the moment something happens; these routes let you backfill history from before you subscribed, and catch anything a delivery missed. A call here uses the same field names as a webhook's call namespace, so one upsert by id serves both.
| Property | Value |
|---|---|
| Method | GET |
| Scopes | call:read |
| Org kind | Seller organizations only |
| Cost | 1 token per request, whatever limit |
| Body | None |
call:read is in the default scope set, so a key minted without an explicit scope list can already call these.
Organization kind
Calls belong to the organization whose agents and numbers handled them — the seller side, as in the dashboard. A buyer-only organization gets 403:
json
{
"error": {
"code": "FORBIDDEN",
"message": "This endpoint is only available to seller organizations."
}
}An organization that is both buyer and seller is served normally.
The call object
| Field | Type | Nullable | Example |
|---|---|---|---|
id | string (uuid) | No | "5c9d3a70-1e84-4b2f-9c6a-8d0f1e3b7a25" |
status | string | No | "ended" |
direction | string | No | "outbound" |
from_e164 | string | No | "+27821234567" |
to_e164 | string | No | "+27829876543" |
duration_sec | number | Yes | 42 |
started_at | string (ISO) | Yes | "2026-08-17T09:14:00.000Z" |
answered_at | string (ISO) | Yes | "2026-08-17T09:14:10.000Z" |
ended_at | string (ISO) | Yes | "2026-08-17T09:15:00.000Z" |
campaign_id | string (uuid) | Yes | "7e8f9a0b-1c2d-4e3f-a4b5-c6d7e8f9a0b1" |
contact_id | string (uuid) | Yes | "8b7a6c5d-4e3f-4a2b-9c8d-7e6f5a4b3c2d" |
agent_kind | string | No | "human" |
outcome | object | Yes | see below |
created_at | string (ISO) | No | "2026-08-17T09:13:58.000Z" |
updated_at | string (ISO) | No | "2026-08-17T09:15:01.000Z" |
outcome, once a disposition has been filed, and null until then:
| Field | Type | Nullable | Example |
|---|---|---|---|
outcome.id | string (uuid) | No | "2d4f6a80-3b5c-4d7e-8f9a-0b1c2d3e4f50" |
outcome.disposition | string | No | "interested" |
outcome.callback_at | string (ISO) | Yes | "2026-08-20T08:00:00.000Z" |
outcome.submitted_at | string (ISO) | No | "2026-08-17T09:15:00.000Z" |
The fields are defined by ApiCallSchema.resource in src/lib/schema/api/api_call.schema.ts. Everything a field means on call.completed and call_outcome.submitted it means here, with three differences worth knowing:
statuscan be any status, not only a terminal one. A call still in progress is listed too. The values arequeued,initiated,ringingandin_progresswhile it is live, then one of the three terminal ones —ended,missedorfailed(CALLS.STATUS.CURRENT_IDSinsrc/lib/const/call.const.ts, andcall.completedsays what each means). A call that is not terminal yet will change — sync it again. The status says only where the call is; how it went (answered, busy, voicemail) isoutcome.disposition. Until 22 October 2026 a finished call readcompleted,busy,no_answerorcanceled; those calls now readended, ormissedfor an inbound call nobody answered, and the change moved theirupdated_at, so a sync byupdated_sincepicks it up once.contact_idis the contact's id only, not thecontactnamespace a webhook carries. It isnullwhen the call reached nobody your organization holds a contact record for — an inbound call, a click-to-call, or a campaign whose contacts belong to another organization.agent_kindis the webhook'sagent.kind:aiwhen a voice AI agent placed the call,humanotherwise — including a call nobody answered.humanmeans "not a voice AI agent", not "a person spoke".
outcome.disposition is one of interested, not_interested, callback, dnq, language_barrier, do_not_call, transferred, hung_up, wrong_number, deceased, voicemail, straight_to_voicemail, no_answer, declined, busy and invalid_number (CAMPAIGNS.DISPOSITION.CURRENT_IDS in src/lib/const/campaign.const.ts; the call_outcome.submitted event says what each means). transferred is a voice AI agent's call put through to a person at one of the campaign's transfer numbers; such a call keeps agent_kind: "ai". Until 22 October 2026 there was also dropped; those calls now read hung_up when somebody answered and declined when nobody did, and the change moved their updated_at, so a sync by updated_since picks it up once. The agent's free-text notes are not published.
An agent's practice calls — the microphone check — are never listed.
GET /api/public/v1/calls
Query parameters
| Parameter | Default | Meaning |
|---|---|---|
limit | 50 | Calls per page, 1–100. |
cursor | — | The next_cursor from the previous page. |
updated_since | — | ISO 8601 with an offset. Turns the list into a change feed. |
Paging is the same as every list's; see Pagination. Any other parameter is a 400.
Two orders
Without updated_since, calls come newest first, by when the call was created. This is the list to read "what happened recently", or to backfill everything once.
With updated_since, the list returns every call changed at or after that instant, oldest change first — the order the sync recipe relies on. A call counts as changed when anything on it changes, its outcome included: filing a disposition moves the call's updated_at. So does work that touches no field listed above — the call's price arriving, its recording being archived — so expect to see a call again shortly after it ends, unchanged. Upsert by id and that is harmless.
A cursor carries its place in one order only. Do not take a cursor from one order and send it with the other.
Request
bash
curl -sS "https://<your-app-domain>/api/public/v1/calls?updated_since=2026-08-17T09:00:00Z&limit=100" \
-H "authorization: Bearer $CALL_CENTER_API_KEY"Response
200 OK
json
{
"data": [
{
"id": "5c9d3a70-1e84-4b2f-9c6a-8d0f1e3b7a25",
"status": "ended",
"direction": "outbound",
"from_e164": "+27821234567",
"to_e164": "+27829876543",
"duration_sec": 42,
"started_at": "2026-08-17T09:14:00.000Z",
"answered_at": "2026-08-17T09:14:10.000Z",
"ended_at": "2026-08-17T09:15:00.000Z",
"campaign_id": "7e8f9a0b-1c2d-4e3f-a4b5-c6d7e8f9a0b1",
"contact_id": "8b7a6c5d-4e3f-4a2b-9c8d-7e6f5a4b3c2d",
"agent_kind": "human",
"outcome": {
"id": "2d4f6a80-3b5c-4d7e-8f9a-0b1c2d3e4f50",
"disposition": "callback",
"callback_at": "2026-08-20T08:00:00.000Z",
"submitted_at": "2026-08-17T09:15:30.000Z"
},
"created_at": "2026-08-17T09:13:58.000Z",
"updated_at": "2026-08-17T09:15:30.000Z"
}
],
"pagination": {
"limit": 100,
"has_more": false,
"next_cursor": null
}
}GET /api/public/v1/calls/{id}
One call, as a single resource in data. A call of another organization, a practice call, and an id that is not a UUID all answer the same 404, so the answer never reveals whether some other organization holds that id:
json
{
"error": {
"code": "NOT_FOUND",
"message": "No such call."
}
}bash
curl -sS https://<your-app-domain>/api/public/v1/calls/5c9d3a70-1e84-4b2f-9c6a-8d0f1e3b7a25 \
-H "authorization: Bearer $CALL_CENTER_API_KEY"Errors
| Situation | Status | Code |
|---|---|---|
| Unknown query parameter | 400 | INVALID_INPUT |
limit outside 1–100 | 400 | INVALID_INPUT |
updated_since not ISO 8601, or with no offset | 400 | INVALID_INPUT |
A cursor this API did not issue | 400 | INVALID_INPUT |
Key lacks call:read | 403 | FORBIDDEN |
| Buyer-only organization | 403 | FORBIDDEN |
| No such call in your organization | 404 | NOT_FOUND |
| Budget exhausted | 429 | TOO_MANY_REQUESTS |
Suggested sync loop
checkpoint = stored value, or the first instant you care about
loop:
page through GET /calls?updated_since=<checkpoint>&limit=100
upsert each call by id
remember the largest updated_at seen
checkpoint = largest updated_at seen, minus one minute
sleep, then loop
on 429 -> sleep retry-after, retry the same pageRun it beside your webhook endpoint rather than instead of it: the webhook is how you hear of a call within seconds, this loop is how you know you never missed one.