Calls and appointments
Queue a lead, then read what came of it. A POST here creates a call_request, not a call — Kleos decides when to dial. Appointments live alongside calls because they are the same story told at the end: one calls:read key reads both.
Queue a call to one business
POST/v1/calls
QUEUES a call — it does not dial one. Kleos picks the moment, inside that number's legal calling window and behind every compliance gate, which is why this returns a `call_request` rather than a call. Send `external_ref` (or an `Idempotency-Key`) and a retry is safe: the same business is never queued twice while a request for it is still open.
Permission: calls:write
Parameters
| Name | In | Type | |
|---|---|---|---|
Idempotency-Key | header | string | Your own unique string. Replaying it returns the first response for 24 hours. |
Body
| Field | Type | |
|---|---|---|
customer_id * | string | Which of your customers this call is placed for. Kleos id or your `external_ref`. |
phone * | string | The number to call. E.164 preferred; a national number is read using the customer's country. |
business_name | string | Who is being called. Used in research and in the call itself. |
contact_name | string | The person to ask for, if you know them. |
notes | string | Anything the agent should know. Up to 2,000 characters. |
external_ref | string | Your own id for this request. Unique per account, and what makes retries safe. |
Response
{
"object": "call_request",
"id": "6b2d0a71-6f1c-4d69-9a37-2f0c1e4b8a55",
"customer_id": "0f2a6c4e-96a2-4f2e-8f2b-6c1a0e73d8b1",
"phone": "+3234567890",
"business_name": "Vandeputte Plumbing",
"contact_name": null,
"external_ref": "lead-99213",
"status": "queued",
"refusal_code": null,
"call_id": null,
"mode": "test",
"created_at": "2026-08-04T09:12:44.000Z"
}Refusals
| Status | Code | When |
|---|---|---|
| 400 | customer_required | `customer_id` was missing. |
| 400 | phone_required | `phone` was missing. |
| 400 | invalid_phone | `phone` could not be read as a real number for the customer's country. |
| 400 | notes_too_long | `notes` exceeded 2,000 characters. |
| 400 | external_ref_too_long | `external_ref` exceeded 128 characters. |
| 404 | customer_not_found | No customer with that id in this mode. |
Read what came of the calls
GET/v1/calls
By default the calls themselves — outcome, duration, summary, and the appointment if one was booked. Pass `view=requests` for what you asked for rather than what happened, which is what to read when a lead has not been called yet.
Permission: calls:read
Parameters
| Name | In | Type | |
|---|---|---|---|
customer_id | query | string | Only this customer's. |
view | query | string | `calls` (default) or `requests`. |
status | query | string | Only with `view=requests`: `queued`, `dialing`, `completed`, `refused`, `cancelled`, `expired`. |
limit | query | integer | How many to return. Defaults to 100, capped at 200. |
Response
{
"object": "list",
"data": [
{
"object": "call",
"id": "9f31c0aa-6d1b-4f2d-8a6b-71c0e2d94f13",
"customer_id": "0f2a6c4e-96a2-4f2e-8f2b-6c1a0e73d8b1",
"status": "completed",
"finished": true,
"outcome": "appointment_booked",
"started_at": "2026-08-04T10:02:11.000Z",
"ended_at": "2026-08-04T10:06:38.000Z",
"duration_seconds": 267,
"summary": "Owner agreed to a 20-minute walkthrough on Thursday.",
"recording_url": "https://api.kleos.click/v1/recordings/9f31c0aa",
"transcript_url": "https://api.kleos.click/v1/transcripts/9f31c0aa",
"appointment_id": "b71f0c2e-2a53-4a10-9d3f-5c0b7e1a9d44",
"created_at": "2026-08-04T10:02:11.000Z"
}
],
"has_more": false
}One call, or one queued request
GET/v1/calls/{id}
`{id}` can be either — you hold a request id from the moment you queue a lead and a call id only once it has been dialled, and this endpoint works out which you handed it. On a call, `finished` tells you whether to stop polling without comparing status strings yourself.
Permission: calls:read
Parameters
| Name | In | Type | |
|---|---|---|---|
id * | path | string | A call id or a call-request id. |
Response
{
"object": "call",
"id": "9f31c0aa-6d1b-4f2d-8a6b-71c0e2d94f13",
"customer_id": "0f2a6c4e-96a2-4f2e-8f2b-6c1a0e73d8b1",
"status": "completed",
"finished": true,
"outcome": "appointment_booked",
"started_at": "2026-08-04T10:02:11.000Z",
"ended_at": "2026-08-04T10:06:38.000Z",
"duration_seconds": 267,
"summary": "Owner agreed to a 20-minute walkthrough on Thursday.",
"recording_url": "https://api.kleos.click/v1/recordings/9f31c0aa",
"transcript_url": "https://api.kleos.click/v1/transcripts/9f31c0aa",
"appointment_id": "b71f0c2e-2a53-4a10-9d3f-5c0b7e1a9d44",
"created_at": "2026-08-04T10:02:11.000Z"
}Refusals
| Status | Code | When |
|---|---|---|
| 404 | call_not_found | No call or queued call of yours matches that id. |
Cancel a queued call
DELETE/v1/calls/{id}
Works only while the request is still `queued`. Once the dialler has claimed it you get a refusal rather than a silent success — being told `cancelled` while a phone is already ringing is the outcome this endpoint exists to prevent.
Permission: calls:write
Parameters
| Name | In | Type | |
|---|---|---|---|
id * | path | string | The call-request id. |
Response
{
"object": "call_request",
"id": "6b2d0a71-6f1c-4d69-9a37-2f0c1e4b8a55",
"status": "cancelled",
"deleted": true
}Refusals
| Status | Code | When |
|---|---|---|
| 404 | call_not_found | No queued call of yours matches that id. |
| 409 | call_not_cancellable | It has already been claimed, dialled or cancelled. |
The meetings your customers got
GET/v1/appointments
The outcome you sell, as a list rather than something to reconstruct by walking every call. `start_at` and `end_at` are exact instants; `timezone` is what the meeting was agreed in, and you need both — rendering the hour in your own timezone tells your customer to arrive at the wrong time. Filter a billing period with `from` and `to`.
Permission: calls:read
Parameters
| Name | In | Type | |
|---|---|---|---|
customer_id | query | string | Only this customer's. |
status | query | string | `booked`, `confirmed`, `reminded`, `completed`, `no_show`, `cancelled` or `rescheduled`. An unrecognised value is ignored rather than returning an empty list. |
from | query | string | Only meetings starting at or after this ISO-8601 instant. |
to | query | string | Only meetings starting before this ISO-8601 instant. |
limit | query | integer | How many to return. Defaults to 100, capped at 200. |
Response
{
"object": "list",
"data": [
{
"object": "appointment",
"id": "b71f0c2e-2a53-4a10-9d3f-5c0b7e1a9d44",
"customer_id": "0f2a6c4e-96a2-4f2e-8f2b-6c1a0e73d8b1",
"call_id": "9f31c0aa-6d1b-4f2d-8a6b-71c0e2d94f13",
"start_at": "2026-08-06T13:30:00.000Z",
"end_at": "2026-08-06T13:50:00.000Z",
"timezone": "Europe/Brussels",
"status": "booked",
"location": "On site — Dorpsstraat 14",
"summary": "20-minute walkthrough with the owner.",
"created_at": "2026-08-04T10:06:38.000Z"
}
],
"has_more": false
}One meeting
GET/v1/appointments/{id}
Resolves the `appointment_id` a call already gives you. A meeting that is not yours returns the same 404 as one that does not exist.
Permission: calls:read
Parameters
| Name | In | Type | |
|---|---|---|---|
id * | path | string | The appointment id. |
Response
{
"object": "appointment",
"id": "b71f0c2e-2a53-4a10-9d3f-5c0b7e1a9d44",
"customer_id": "0f2a6c4e-96a2-4f2e-8f2b-6c1a0e73d8b1",
"call_id": "9f31c0aa-6d1b-4f2d-8a6b-71c0e2d94f13",
"start_at": "2026-08-06T13:30:00.000Z",
"end_at": "2026-08-06T13:50:00.000Z",
"timezone": "Europe/Brussels",
"status": "booked",
"location": "On site — Dorpsstraat 14",
"summary": "20-minute walkthrough with the owner.",
"created_at": "2026-08-04T10:06:38.000Z"
}Refusals
| Status | Code | When |
|---|---|---|
| 404 | appointment_not_found | No appointment of yours matches that id. |