Calls
The Calls API lets you list call history, view call details with transcripts, download recordings, initiate outbound calls, and run batch call campaigns.
Every endpoint in this section accepts both authentication methods -- Bearer JWT and API key (X-API-Key). This means an automated integration (for example, a handler receiving the post-call webhook) can list calls and download recordings with an API key alone.
List Calls
GET /calls
Returns a paginated list of call logs, ordered by start time (newest first).
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
page_size | integer | 20 | Items per page (max 100) |
direction | string | -- | Filter by inbound or outbound |
status | string | -- | Filter by call status (e.g. completed, failed) or outcome (e.g. voicemail, caller_hangup, answered_no_session) |
search | string | -- | Search by phone number (from or to) |
Response
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"call_sid": "CA1234567890abcdef",
"direction": "inbound",
"from_number": "+15551234567",
"to_number": "+15559876543",
"start_time": "2026-03-01T14:30:00Z",
"end_time": "2026-03-01T14:32:45Z",
"duration": 165.0,
"status": "completed",
"outcome": "completed",
"error_reason": null,
"created_at": "2026-03-01T14:30:00Z",
"agent_name": "Support Agent"
}
],
"total": 142,
"page": 1,
"page_size": 20
}
Call Log Fields
| Field | Type | Description |
|---|---|---|
id | uuid | Internal call log ID |
call_sid | string | Twilio Call SID |
direction | string | inbound or outbound |
from_number | string | Caller phone number |
to_number | string | Called phone number |
start_time | datetime | Call start timestamp |
end_time | datetime | null | Call end timestamp |
duration | float | null | Call duration in seconds. Reconciled against the recorded audio, so it cannot run far past the audio the call actually carried (see below). |
status | string | Call status (completed, failed, in-progress, queued) |
outcome | string | null | Call outcome (completed, caller_hangup, no_answer, timeout, transferred, voicemail, invalid_number, answered_no_session, failed). timeout means the call was ended on our side rather than by the caller - a silence limit, a maximum-duration limit, or a line that stopped carrying audio; caller_hangup is a genuine caller disconnect; voicemail means an answering machine picked up, whether detection caught it or the agent recognised the greeting; answered_no_session means the call was answered and billed but no conversation was captured and the reason is unknown (see below). |
error_reason | string | null | Error description if the call failed |
created_at | datetime | Record creation timestamp |
agent_name | string | null | Name of the agent that handled the call |
Answered Calls With No Conversation
outcome: "answered_no_session" marks a call the telephony provider answered and ended normally, but for which the voice pipeline never recorded a session. The record carries a real duration, start_time and end_time, and status: "completed", while transcript, recording_url and call_analysis stay null because nothing was captured. error_reason says so in plain language.
Treat it as its own case rather than folding it into a no-answer:
- It is not
no_answerorbusy. Those mean the call never connected. This one did connect, and the person on the other end heard whatever the line carried. - It is not
voicemaileither. A call an answering machine picked up is labelledvoicemail, because something identified the machine.answered_no_sessionis what is left when nothing did, so the cause of the missing conversation is genuinely unknown. - Redialling on it is a judgement call. The callee may have been spoken to already, and there is no transcript to check.
- The
call.completedwebhook fires for it like any other terminal call, with the sameoutcomeand withtranscriptandrecording_urlset tonull.
How duration Relates to the Recording
duration measures the call from the moment the session opened to the moment it closed, so it is normally a second or two longer than the recording: setup and teardown sit either side of the audio.
Occasionally a line stops carrying audio without ever telling us the call is over. When that happens the session is closed on our side shortly after the audio stops, the outcome is timeout, and duration is reconciled against the recording so the record - and the usage it feeds - reflects the audio the call actually carried rather than however long the line stayed open. Calls whose duration and recording already agree are never adjusted.
Get Call Detail
GET /calls/{call_id}
Returns a single call with full details including the conversation transcript and provider usage metrics.
Response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"call_sid": "CA1234567890abcdef",
"direction": "inbound",
"from_number": "+15551234567",
"to_number": "+15559876543",
"start_time": "2026-03-01T14:30:00Z",
"end_time": "2026-03-01T14:32:45Z",
"duration": 165.0,
"status": "completed",
"outcome": "completed",
"error_reason": null,
"created_at": "2026-03-01T14:30:00Z",
"agent_name": "Support Agent",
"transcript": [
{
"role": "user",
"content": "Hi, I need help with my order.",
"timestamp": "2026-03-01T14:30:08Z"
},
{
"role": "assistant",
"content": "Of course! Could you share your order number?",
"timestamp": "2026-03-01T14:30:11Z"
}
],
"provider_usage": {
"deepgram": { "type": "stt", "model": "nova-2" },
"openai": { "type": "llm", "model": "gpt-4o" },
"elevenlabs": { "type": "tts", "model": "eleven_turbo_v2" }
},
"recording_url": "call-CA1234567890abcdef.wav"
}
Additional Detail Fields
| Field | Type | Description |
|---|---|---|
transcript | array | null | Conversation turns with role, content and timestamp, plus interrupted on a turn the caller cut off (see below) |
provider_usage | object | null | Provider names and models used during the call |
recording_url | string | null | Filename of the WAV recording (if recording was enabled) |
Turn Timestamps
Each turn carries the moment it happened, not the moment the record was written, so a transcript can be laid back over the recording and the call reconstructed turn by turn.
Interrupted Turns
When a caller speaks over the agent and the agent stops mid-sentence, that turn records only the text the caller actually heard. It carries "interrupted": true and its content ends with [cut off by caller]. The caller utterance that interrupted is prefixed with [interrupting]:
"transcript": [
{ "role": "user", "content": "Can you book me a table?" },
{
"role": "assistant",
"content": "Sure, I can book that for [cut off by caller]",
"interrupted": true
},
{ "role": "user", "content": "[interrupting] actually make it Friday" },
{ "role": "assistant", "content": "Friday it is. What time suits you?" }
]
The interrupted key is present only on turns that were cut off, so parse it as optional. Transcripts for interrupted turns therefore match the audio recording rather than the longer answer the agent had generated.
The cut-off point is whatever had actually reached the caller at the moment they cut in. The agent's audio keeps playing for a moment after the reply has been composed, so a turn the caller talked over in that window is marked "interrupted": true and truncated on the same terms.
Download Recording
GET /calls/{call_id}/recording
Downloads the WAV recording file for a call. Returns 404 if no recording is available.
The call.completed webhook includes a recording_download_url pointing at this endpoint. Fetch it with your API key in the X-API-Key header to retrieve the audio.
Response
200 OK with Content-Type: audio/wav and the binary file content.
Initiate Outbound Call
POST /calls
Queues a single outbound call. The call is processed asynchronously at 1 call per second by a background worker. The response returns immediately with a call_id and a placeholder call_sid that is updated once Twilio assigns the real SID.
This endpoint accepts both Bearer JWT and API key authentication.
Request Body
{
"to_number": "+15551234567",
"from_number": "+15559876543",
"agent_id": "550e8400-e29b-41d4-a716-446655440000",
"variables": {
"customer_name": "Jane Doe",
"company_name": "Globex"
}
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
to_number | string | Yes | Destination phone number in E.164 format |
from_number | string | No | Caller ID number. If omitted, resolved from the agent's assigned number or the SIP config default. |
agent_id | string | No | Agent UUID to handle the call. If omitted, the agent assigned to from_number is used. |
variables | object | No | Per-call overrides for the target agent's custom variables, as a map of variable name to string value. Applies to this call only and never changes the agent's saved defaults. Every key must be a custom variable defined on the target agent; a built-in variable name or an unknown key returns 422 listing the offending keys. |
Unlike the dashboard editor, which only warns about unrecognised placeholders and still lets you save, this endpoint validates variables strictly. A key that matches a built-in variable name (built-ins are never overridable) or that is not a custom variable defined on the target agent is rejected with 422, and the response lists the offending keys. This surfaces integration mistakes loudly instead of silently dropping data. See Dynamic Variables for the variable model.
Request Headers
| Header | Required | Description |
|---|---|---|
Idempotency-Key | No | Your own identifier for this call submission (max 255 characters). Repeating the same key returns the original call instead of dialling again. See Duplicate Suppression. |
Response
202 Accepted
{
"call_id": "550e8400-e29b-41d4-a716-446655440000",
"call_sid": "queued-550e8400e29b",
"status": "queued",
"deduplicated": false,
"dedupe_reason": null
}
The call_sid will be a placeholder prefixed with queued- until the background worker initiates the call with Twilio.
Duplicate Suppression
Queueing a call is fire-and-forget, so an automation that retries -- or that fires a burst of triggers before its own "already called" bookkeeping has caught up -- could otherwise ring the same person once per request. Two guards sit in front of the queue, and both are safe under bursts of simultaneous requests: only one of them can win, no matter how many arrive at the same instant.
Idempotency key. Send an Idempotency-Key header with your own identifier for the submission (a workflow run id, a row id, a UUID -- anything stable for that one logical call). The first request queues the call and binds the key to it for 24 hours. Any repeat of the same key within that window queues nothing and returns the original call's call_id and call_sid. The key is scoped to your account and to the endpoint, so the same value can be reused independently on POST /calls and POST /calls/batch. Only the key is compared, never the request body.
Per-number cooldown. Even without a key, a second call to a number you have just called is suppressed for a short cooldown (60 seconds by default, configurable per deployment and possible to switch off). This is a safety net against retry storms; it is not a substitute for sending an idempotency key.
A suppressed request is not an error. It returns 202 Accepted with the identifiers of the call that was already queued, plus:
| Field | Type | Description |
|---|---|---|
deduplicated | boolean | true when this request queued nothing |
status | string | duplicate instead of queued |
dedupe_reason | string | null | idempotency_key or number_cooldown |
{
"call_id": "550e8400-e29b-41d4-a716-446655440000",
"call_sid": "queued-550e8400e29b",
"status": "duplicate",
"deduplicated": true,
"dedupe_reason": "idempotency_key"
}
Because the response carries the original call_id, a client that retries after a timeout can safely treat a duplicate response as success and keep polling the same call. Track deduplicated in your integration: a steady stream of true means your workflow is submitting more triggers than it intends to.
The first call to any number, with or without a header, behaves exactly as it always has.
Batch Outbound Calls
POST /calls/batch
Queues multiple outbound calls at once. All calls share the same from_number and agent_id, and are processed at 1 call per second.
Request Body
{
"to_numbers": ["+15551111111", "+15552222222", "+15553333333"],
"from_number": "+15559876543",
"agent_id": "550e8400-e29b-41d4-a716-446655440000"
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
to_numbers | array[string] | Yes | List of destination phone numbers |
from_number | string | No | Shared caller ID for all calls |
agent_id | string | No | Agent to handle all calls in the batch |
Request Headers
| Header | Required | Description |
|---|---|---|
Idempotency-Key | No | Your own identifier for this batch submission (max 255 characters). Repeating the same key returns the original batch_id and queues nothing. |
Response
202 Accepted
{
"batch_id": "batch-abc123",
"total": 3,
"status": "processing",
"deduplicated": false,
"dedupe_reason": null,
"skipped_numbers": []
}
total is the number of calls this request queued, which is not always the length of to_numbers.
Duplicate Suppression in a Batch
Duplicate suppression applies to batches as well, with one difference: the per-number cooldown is evaluated number by number, so an overlapping list still sends the numbers that are new.
- Repeat
Idempotency-Key: nothing is queued. The response carries the originalbatch_id,total: 0,status: "duplicate", and every requested number inskipped_numbers. - Numbers under cooldown: the rest of the list is queued as normal.
totalcounts only those, the suppressed numbers are listed inskipped_numbers, anddeduplicatedistruewithdedupe_reason: "number_cooldown". A number repeated twice inside one payload is dialled once for the same reason. - Every number under cooldown: nothing is queued and
statusisduplicate.
{
"batch_id": "batch-def456",
"total": 2,
"status": "processing",
"deduplicated": true,
"dedupe_reason": "number_cooldown",
"skipped_numbers": ["+15551111111"]
}
Get Batch Status
GET /calls/batch/{batch_id}
Check the progress of a batch call job.
Response
{
"batch_id": "batch-abc123",
"total": 3,
"queued": 1,
"initiated": 1,
"failed": 1,
"calls": [
{ "to_number": "+15551111111", "status": "initiated", "call_sid": "CA..." },
{ "to_number": "+15552222222", "status": "queued", "call_sid": null },
{ "to_number": "+15553333333", "status": "failed", "call_sid": null, "error": "Invalid number" }
]
}