Skip to main content

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

ParameterTypeDefaultDescription
pageinteger1Page number
page_sizeinteger20Items per page (max 100)
directionstring--Filter by inbound or outbound
statusstring--Filter by call status (e.g. completed, failed) or outcome (e.g. voicemail, caller_hangup, answered_no_session)
searchstring--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

FieldTypeDescription
iduuidInternal call log ID
call_sidstringTwilio Call SID
directionstringinbound or outbound
from_numberstringCaller phone number
to_numberstringCalled phone number
start_timedatetimeCall start timestamp
end_timedatetime | nullCall end timestamp
durationfloat | nullCall duration in seconds. Reconciled against the recorded audio, so it cannot run far past the audio the call actually carried (see below).
statusstringCall status (completed, failed, in-progress, queued)
outcomestring | nullCall 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_reasonstring | nullError description if the call failed
created_atdatetimeRecord creation timestamp
agent_namestring | nullName 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_answer or busy. 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 voicemail either. A call an answering machine picked up is labelled voicemail, because something identified the machine. answered_no_session is 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.completed webhook fires for it like any other terminal call, with the same outcome and with transcript and recording_url set to null.

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

FieldTypeDescription
transcriptarray | nullConversation turns with role, content and timestamp, plus interrupted on a turn the caller cut off (see below)
provider_usageobject | nullProvider names and models used during the call
recording_urlstring | nullFilename 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

FieldTypeRequiredDescription
to_numberstringYesDestination phone number in E.164 format
from_numberstringNoCaller ID number. If omitted, resolved from the agent's assigned number or the SIP config default.
agent_idstringNoAgent UUID to handle the call. If omitted, the agent assigned to from_number is used.
variablesobjectNoPer-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.
Strict validation (unlike the dashboard)

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

HeaderRequiredDescription
Idempotency-KeyNoYour 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:

FieldTypeDescription
deduplicatedbooleantrue when this request queued nothing
statusstringduplicate instead of queued
dedupe_reasonstring | nullidempotency_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

FieldTypeRequiredDescription
to_numbersarray[string]YesList of destination phone numbers
from_numberstringNoShared caller ID for all calls
agent_idstringNoAgent to handle all calls in the batch

Request Headers

HeaderRequiredDescription
Idempotency-KeyNoYour 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 original batch_id, total: 0, status: "duplicate", and every requested number in skipped_numbers.
  • Numbers under cooldown: the rest of the list is queued as normal. total counts only those, the suppressed numbers are listed in skipped_numbers, and deduplicated is true with dedupe_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 status is duplicate.
{
"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" }
]
}