Use the customer API operations
Browse phone-system topology, fetch call logs, analyses, voicemails, and recordings, and reproduce the dashboard call log export in bulk with the customer API.
Version 1 of the customer API publishes 24 read operations: phone-system discovery; list and detail
reads for phones, phone numbers, extensions, Call Assistants, call groups, and call menus; the
call-log, analysis, voicemail, and recording reads described below; and the two bulk reporting
operations that reproduce the dashboard’s Call Log export. The exact parameters, response
fields, and error responses are in the generated reference page at /customer/api/reference. It
is produced from the same approved contract the service enforces, so it cannot drift from
behavior.
Every version 1 operation currently carries the beta lifecycle stage. What beta permits — and how an operation is promoted to stable — is covered in API versioning and errors.
Verify your key works
Make GET /v1/customer/phone-systems your first call. It needs no scope — every valid customer
key can call it — and it returns the phone systems the key’s grant currently covers:
GET /v1/customer/phone-systems HTTP/1.1
Host: api.steerphones.com
X-API-Key: sk_phones_…
Accept: application/json
{
"data": [
{ "id": "b3f1a5c2-1d44-4c19-9a1e-2f7d8c4b0e11", "name": "Downtown Service Center" },
{ "id": "0f6a1c2e-71d3-4b19-8a10-2b0f4c9a5f22", "name": "Northside Service Center" }
]
}
A 200 proves the key is valid, and the id values are the {phoneSystemId} every other
operation’s path needs. An empty data array is still a healthy response — the key is valid but
its grant covers no systems right now. Coverage is resolved live on every request, so when a phone
system joins or leaves the group or organization a key was issued for, this list — and what the
key can read — changes with it, without the key being reissued.
One phone system per request
Every resource operation is scoped to a single phone system named in the path:
GET /v1/customer/phone-systems/{phoneSystemId}/call-logs
GET /v1/customer/phone-systems/{phoneSystemId}/call-logs/{callLogId}
GET /v1/customer/phone-systems/{phoneSystemId}/call-log-reports
GET /v1/customer/phone-systems/{phoneSystemId}/call-log-reports/dimensions
GET /v1/customer/phone-systems/{phoneSystemId}/call-analyses
GET /v1/customer/phone-systems/{phoneSystemId}/call-analyses/{id}
GET /v1/customer/phone-systems/{phoneSystemId}/voicemails
GET /v1/customer/phone-systems/{phoneSystemId}/voicemails/{voicemailId}
GET /v1/customer/phone-systems/{phoneSystemId}/recordings
GET /v1/customer/phone-systems/{phoneSystemId}/recordings/{recordingSid}
GET /v1/customer/phone-systems/{phoneSystemId}/recordings/{recordingSid}/url
GET /v1/customer/phone-systems/{phoneSystemId}/phones
GET /v1/customer/phone-systems/{phoneSystemId}/phones/{phoneId}
GET /v1/customer/phone-systems/{phoneSystemId}/phone-numbers
GET /v1/customer/phone-systems/{phoneSystemId}/phone-numbers/{phoneNumberId}
GET /v1/customer/phone-systems/{phoneSystemId}/extensions
GET /v1/customer/phone-systems/{phoneSystemId}/extensions/{extensionId}
GET /v1/customer/phone-systems/{phoneSystemId}/agent-configs
GET /v1/customer/phone-systems/{phoneSystemId}/agent-configs/{agentConfigId}
GET /v1/customer/phone-systems/{phoneSystemId}/call-groups
GET /v1/customer/phone-systems/{phoneSystemId}/call-groups/{callGroupId}
GET /v1/customer/phone-systems/{phoneSystemId}/call-menus
GET /v1/customer/phone-systems/{phoneSystemId}/call-menus/{callMenuId}
There is no cross-system read: a key covering several systems iterates them one at a time. Keep the rate budget in mind when you do — the allowance belongs to the key and is shared across every system it covers, as described under rate limits.
A phone system outside the key’s grant answers exactly like a phone system that does not exist: the documented not-found shape. The same posture applies to every resource identifier — a record belonging to another phone system is indistinguishable from a record that was never created. Treat 404 as “not readable with this key” rather than proof of nonexistence, and check coverage with the key-introspection call above before debugging further.
Browse phone-system topology
Topology reads follow a browse-detail-follow pattern:
- List a resource to build a compact inventory and collect stable IDs.
- Fetch a detail row when you need hardware, membership, routing, or playback information.
- Follow each typed reference using its own
phoneSystemId, resourcetype, andid.
Every list uses the shared pagination envelope and returns all retained lifecycle states by default, including inactive or released rows. Filters are exact matches:
| Resource | Scope | Exact filters | Detail adds |
|---|---|---|---|
| Phones | phones:read |
status, extensionId |
Hardware identity, device role, parent phone, slot, MAC address, firmware, and extension assignment |
| Phone numbers | phone-numbers:read |
status, phoneNumber |
Customer-safe origin (managed or carrier_hosted), country, and primary-number state |
| Extensions | extensions:read |
status, number |
Assigned phones, unanswered-call behavior, and voicemail greeting playback |
| Call Assistants | agent-configs:read |
status, agentNumber |
The same routing identity as the list row; prompts, models, and credentials remain private |
| Call groups | call-groups:read |
status, number |
Ordered members, ring pattern, unanswered-call behavior, and voicemail greeting playback |
| Call menus | call-menus:read |
status |
Prompt playback, ordered digit choices and actions, direct extension dialing, and no-input behavior |
List rows are deliberately compact. Detail rows show only directly configured topology; they do not calculate schedules, evaluate current business hours, or reconstruct an effective call route.
Follow typed references
A resource-bearing relationship always uses the same complete shape, whether the target is local or belongs to another phone system:
{
"type": "extension",
"id": "4b3d6f0c-9e85-40cc-8d71-6c0e2f5a8b14",
"phoneSystemId": "0d9f2b6e-5a41-4c88-9b3d-2f6e8a1c4d70"
}
Use the reference’s phoneSystemId, not the phone system from the response that contained it:
Reference type |
Follow with |
|---|---|
phone |
GET …/{phoneSystemId}/phones/{id} |
extension or extension-voicemail |
GET …/{phoneSystemId}/extensions/{id} |
agent-config |
GET …/{phoneSystemId}/agent-configs/{id} |
call-group or call-group-voicemail |
GET …/{phoneSystemId}/call-groups/{id} |
call-menu |
GET …/{phoneSystemId}/call-menus/{id} |
forward, play-audio, voicemail, directory, messaging, or hangup behavior |
No resource fetch; use the literal or behavior carried by the action |
A typed reference is the persisted routing coordinate; its target is not existence-checked while the containing resource is read. If following it returns not-found, the target is out of the key’s grant, belongs to another tenant, or was deleted. Those cases are intentionally indistinguishable: treat all three as an unavailable target and do not retry the same reference.
Playback URLs in topology detail
Extension and call-group greetings and call-menu prompts or playback actions may carry a public playback URL. These URLs are unauthenticated and do not expire. Store them only as needed and do not confuse them with the short-lived signed recording URLs described below. Revoking an API key does not revoke topology audio already retrieved. If that audio must be invalidated, re-upload it under a new storage key and update the resource to use the new media.
Keys issued before these topology operations were published do not gain access automatically. Have Steer Phones reissue the key with the exact topology scopes it needs before calling them.
Identifiers webhooks hand you
The operations accept the identifiers webhook payloads deliver, so the webhook-to-fetch flow needs no translation step:
| Identifier | Shape | Delivered by | Fetches |
|---|---|---|---|
callSid |
CA… string |
Every call event | Call-log list filter; call-analysis list filter |
callLogId |
UUID | call.transcription.completed and call.analysis.completed |
Call-log detail (the transcript); call-analysis list filter |
voicemailId |
UUID | voicemail.created and voicemail readiness events |
Voicemail detail (the transcript) |
recordingSid |
RE… string |
call.recording.available; list and detail rows |
Recording detail; the signed audio URL |
Call-analysis rows also carry their own id, used by the call-analysis detail operation; it
appears in list responses rather than webhooks.
Call logs and transcripts
The call-log list returns the phone system’s calls newest first, bounded by an inclusive
dateFrom/dateTo window (ISO 8601 UTC, applied to createdAt) and optionally filtered to an
exact callSid — the follow-up fetch for any call event. Rows carry direction, parties, status,
outcome, duration, and the recordingSid when the call was recorded.
The detail operation, by callLogId, adds the transcript: transcribedText plus a
transcriptionStatus of exactly one of:
completed— the transcript is present. It can legitimately be empty text.processing— transcription is queued or running; keep polling, or subscribe tocall.transcription.completedinstead.failed— transcription was attempted and did not succeed. Terminal.unavailable— transcription was never attempted or was skipped for this call. Terminal.
Fetching right after a call ends therefore distinguishes “arriving soon” (processing) from the
two terminal outcomes — no timeout guessing needed. Requires calls:read.
Recreate the call log export
The call-log operations above are the normalized resource: Call Log-owned values, with related records left as identifiers you follow. That shape is right for a webhook follow-up and wrong for a recurring export, where every row would cost its own detail request.
Two operations cover the export case instead:
GET /v1/customer/phone-systems/{phoneSystemId}/call-log-reports
GET /v1/customer/phone-systems/{phoneSystemId}/call-log-reports/dimensions
A report row carries the enriched values in a single read: call identity and lineage, direction and
parties, forwarding, lifecycle state and business outcome, conversion and scoring, category and
sentiment codes with their current labels and scores, representative attribution, call and
recording durations, transcript readiness, call group and extension context, emergency and edited
flags, tags, the route summary, and the timestamps every exported time value is derived from. The
default /call-logs operations are unchanged and keep their existing shape.
What reporting requires, and what it never returns
calls:readacross the whole phone system. A key whosecalls:readis narrowed to one person’s own calls can read those calls through the call-log operations, but both reporting operations refuse it with 403 before any reporting data is read. A bulk extract of the whole phone system is a different authority from reading your own calls, and it is not a separate scope you can ask for — it is how broadly thecalls:readyou already hold was granted. See Customer API authentication.- No recording audio and no signed media URL. A row names its
recordingSid; the audio behind it stays with the recording operations andrecordings:read. Reporting never becomes a route to audio.
Both responses are sent with Cache-Control: no-store. They carry call content, so keep them in
your own systems rather than in a shared cache.
Choose a window
Every request is one bounded extraction. Three parameters define it:
timeField—createdAt(the default) orupdatedAt. It selects both the filter and the ordering.dateFromanddateTo— both required, ISO 8601 UTC. The window is half-open:dateFromis included anddateTois excluded, so consecutive windows abut exactly without ever counting a row twice.
createdAt is historical extraction: give it a month, walk it, and you have that month’s calls.
updatedAt is changed-record catch-up. A call’s updatedAt advances whenever a value that affects
its reporting meaning changes — analysis arriving after the call, an edited outcome or conversion,
a scorecard, a transcript becoming available or being removed, a tag applied or removed — so an
update-time window whose bounds contain that moment returns the call again, with its current
values.
These are report queries you bound, not a change feed. They prescribe no polling cadence, no overlap, and no checkpoint, and they promise nothing about losslessness. Those are yours to choose, and the two rules below are what make any choice safe.
End each window short of now
Both timestamps are stamped when a row is written, and the write becomes visible slightly later, when it is committed. A row can therefore become visible carrying a timestamp that falls inside a window you have already walked to the end.
The recommendation is to end each window at least five minutes before the current time, and to
start the next window at that same boundary. Take it for what it is: a practical margin, not a
guarantee. There is no published bound on how long a write can take, so a row that commits later
than the margin can still be missed by a window that has already closed. What makes the traversal
correct is not the five minutes — it is applying rows idempotently by Call Log id and re-running
an overlapping window whenever you need certainty rather than a good-enough margin. Both are cheap
precisely because a repeated row is harmless.
Traverse with the cursor
Rows come back ascending by the selected timestamp and then by id, so two calls sharing a
timestamp still have exactly one order. The envelope is not the page-number envelope the standard
lists use:
{
"data": [],
"nextCursor": "eyJ2IjoidjEi…",
"hasMore": true
}
There is no total — counting a window would cost a second pass over it and would be stale on
arrival. Send nextCursor back unchanged as the cursor parameter until hasMore is false.
That is the end of the window, and only then is the extraction complete.
What a row looks like
One row, with includeTranscripts=true so every field is visible at once. Every field is present
on every row — a value that does not apply is null rather than missing, so you can map columns
positionally without existence checks:
{
"id": "7c1f9a3e-2b64-4d05-9f18-3a7c0e5b1d42",
"phoneSystemId": "b3f1a5c2-1d44-4c19-9a1e-2f7d8c4b0e11",
"callSid": "CA9f4c2e7b18a6d053f19b2c7e4a8d6051",
"parentCallSid": null,
"direction": "inbound",
"startTime": "2026-08-14T15:04:11.000Z",
"endTime": "2026-08-14T15:07:38.000Z",
"durationSeconds": 207,
"createdAt": "2026-08-14T15:04:11.412Z",
"updatedAt": "2026-08-14T15:12:02.887Z",
"from": "+15555550123",
"fromName": "Dana Whitfield",
"fromDisplay": "+1 (555) 555-0123",
"fromNumber": "+15555550123",
"to": "+15555550100",
"toDisplay": "Main Line",
"toNumber": "+15555550100",
"forwardedFrom": null,
"status": "completed",
"outcome": "answered",
"callOutcomeCode": "appointment_booked",
"isConverted": true,
"conversionValue": "450.00",
"outcomeSource": "ai_analysis",
"category": { "code": "service_request", "label": "Service Request" },
"overallSentiment": { "code": "positive", "label": "Positive", "score": 0.82 },
"employeeSentiment": { "code": "positive", "label": "Positive", "score": 0.9 },
"customerSentiment": { "code": "neutral", "label": "Neutral", "score": 0.51 },
"sentimentSource": "ai_analysis",
"scorecardScore": 88,
"representative": {
"id": "0d9f2b6e-5a41-4c88-9b3d-2f6e8a1c4d70",
"name": "Sam Ortega",
"state": "identified",
"confidence": 0.94,
"source": "ai_analysis"
},
"hasRecording": true,
"recordingSid": "RE5b2c8e1a4d70f39c6b1e2a5d8c4f0e73",
"recordingDurationSeconds": 205,
"transcriptionStatus": "completed",
"callGroup": {
"id": "4b3d6f0c-9e85-40cc-8d71-6c0e2f5a8b14",
"name": "Sales Team",
"number": "120"
},
"answeredByExtension": {
"id": "0f6a1c2e-71d3-4b19-8a10-2b0f4c9a5f22",
"name": "Sam Ortega",
"number": "101"
},
"fromExtension": null,
"isEmergency": false,
"isEdited": false,
"tags": [
{
"id": "9e3c7a10-4f28-4b6d-95a7-1c0e8b2f5d64",
"name": "Google",
"group": "marketing_source",
"color": "#4285F4",
"source": "dni_mapping",
"confidence": null
}
],
"routeSummary": "Main Line → Sales Team → x101",
"transcript": {
"text": "Thanks for calling. How can I help?…",
"turns": [
{
"text": "Thanks for calling. How can I help?",
"speaker": { "kind": "role", "role": "agent" },
"startSeconds": 0,
"endSeconds": 2.4,
"confidence": 0.97
},
{
"text": "I need to book a service appointment.",
"speaker": { "kind": "role", "role": "customer" },
"startSeconds": 2.9,
"endSeconds": 5.1,
"confidence": 0.93
}
]
}
}
transcript appears only when you send includeTranscripts=true. With it off the property is
absent entirely — not null. With it on, a call whose transcript is not currently stored returns
"transcript": null and a transcriptionStatus of processing, failed, or unavailable.
conversionValue is a decimal string, not a number, so no precision is lost between the call
and your ledger. Parse it with a decimal type. startSeconds, endSeconds, confidence, and
score are JSON numbers; a timing the vendor never recorded is null rather than 0.
A speaker is one of { "kind": "role", "role": "agent" | "customer" },
{ "kind": "numbered", "ordinal": 2 } when the vendor separated voices without identifying them,
or { "kind": "unattributed" }.
Six rules govern the cursor:
- It is opaque. Send it back exactly as received. Its contents are not a contract: do not parse one, do not construct one, and do not hold one between runs.
- It continues one window. A cursor is valid only for the same phone system,
timeField,dateFrom, anddateTo. Send it with any of those changed and the request fails validation with a pointer at/cursor. - It is not a delta token and not a snapshot. It does not isolate you from writes landing while you walk, and it is not a durable position you can resume from tomorrow.
- It carries no authorization. Every page is authorized from the key on that request, so a revoked or narrowed key stops working mid-traversal rather than riding the cursor.
- Restarting is always safe. If a cursor is refused, restart the same window from the beginning with no cursor. You will re-receive rows you already have, which is harmless.
- Rows can repeat. A row updated while you are walking an update-time window can be delivered
twice. Apply every row as an upsert keyed on
idand duplicates cost nothing.
Physical deletion is the one change the window cannot show you: a deleted call log produces no tombstone in this version. The operation returns the retained calls that were added or changed inside the window, and nothing else.
limit and includeTranscripts are per-page controls
limit— rows per page. Default 25, maximum 100, or maximum 25 when transcripts are included.includeTranscripts—trueorfalse, defaulting tofalse.
Neither is bound into the cursor, and neither is fixed for the traversal. You may change either in
either direction on a continuation, as long as the request you send is itself valid — dropping to
limit=5 because pages are running large, or turning transcripts off for the rest of a window, are
both ordinary. A change affects only the rows returned after it. It never changes which rows the
window contains, their order, or where the cursor sits.
Extract a historical window
GET /v1/customer/phone-systems/b3f1a5c2-1d44-4c19-9a1e-2f7d8c4b0e11/call-log-reports?timeField=createdAt&dateFrom=2026-08-01T00:00:00Z&dateTo=2026-09-01T00:00:00Z&limit=100 HTTP/1.1
Host: api.steerphones.com
X-API-Key: sk_phones_…
Accept: application/json
Then follow the cursor until the window is exhausted:
GET /v1/customer/phone-systems/b3f1a5c2-1d44-4c19-9a1e-2f7d8c4b0e11/call-log-reports?timeField=createdAt&dateFrom=2026-08-01T00:00:00Z&dateTo=2026-09-01T00:00:00Z&limit=100&cursor=eyJ2IjoidjEi… HTTP/1.1
The window’s upper bound is well in the past, so it gains no new rows while you walk it. Upsert
each row by id and treat the extraction as complete only when hasMore is false.
Pick up what changed
GET /v1/customer/phone-systems/b3f1a5c2-1d44-4c19-9a1e-2f7d8c4b0e11/call-log-reports?timeField=updatedAt&dateFrom=2026-09-10T12:00:00Z&dateTo=2026-09-11T12:00:00Z HTTP/1.1
Choose dateTo at least five minutes before the current time, exhaust every page, then start the
next run’s window at that same dateTo. Because rows are upserted by id, widening a window or
re-running one you already walked costs nothing but the work — which is what makes a deliberate
overlap, after an interruption or a suspiciously quiet run, a reasonable thing to do.
Transcripts in bulk
Every row always carries transcriptionStatus, whether or not you asked for transcripts. It uses
the same four values as the call-log and voicemail detail operations, and it describes what is
retrievable now:
completed— transcript text is stored and returned by this read. It can legitimately be empty text.processing— transcription is queued or running.failed— transcription was attempted and did not succeed.unavailable— there is no transcript text to return. Transcription was never attempted, was skipped, or the text has since been removed.
Two consequences are worth building for:
completeddescribes the text, not the last attempt. It means canonical text is retrievable right now. It does not mean a re-transcription has settled: while earlier text is still stored, a replacement run that is queued, in flight, or failed leaves the status atcompleted.- Structured speaker detail never creates availability. Turn-level detail enriches text that
exists; it is not a transcript on its own. A row with no canonical text reads
unavailableand returns no transcript whatever else is stored beside it, and a row whose text has no usable structure returns the text with an emptyturnsarray.
With includeTranscripts=true, each row gains a transcript object: the canonical text, plus
turns carrying each turn’s text, its speaker, startSeconds, endSeconds, and confidence as
JSON numbers when the transcription reported them. A speaker is named without provider labels — the
party the analysis resolved (agent or customer), or a numbered speaker in order of first
appearance, or explicitly unattributed.
Presence and absence are distinct on purpose:
includeTranscripts=trueand no stored text —"transcript": null. The row is still returned in full.includeTranscripts=false— thetranscriptproperty is absent entirely. A metadata page never reads a transcript body at all, which is why it is dramatically cheaper and allowed four times the page size.
A transcript removed later — by your retention settings or by an explicit deletion — moves that row
from completed to unavailable and returns no text, and the removal advances the row’s
updatedAt, so an update-time window picks the change up like any other.
Refresh display values with dimensions
Report rows keep stable identifiers and codes permanently — callGroup.id,
answeredByExtension.id, fromExtension.id, representative.id, category.code, the three
sentiment codes, and each tags[].id — and resolve display values as they stand when the row is
returned. So a rename reaches rows you fetch afterwards but not rows you already stored. The
dimensions operation is how you refresh what you stored, without re-extracting history:
GET /v1/customer/phone-systems/{phoneSystemId}/call-log-reports/dimensions HTTP/1.1
It returns generatedAt and seven families: callGroups, extensions, handlerEntries (the
representatives calls are attributed to), categories, overallSentiments, employeeSentiments,
and customerSentiments.
- Replace-all. Each snapshot is complete for what it covers. Replace your lookup table with it rather than merging into it.
- Referenced-only membership. Members are exactly the identifiers and codes that at least one retained call in your history still references. This is not an inventory of the phone system: unused call groups, extensions, and definitions never appear, and nothing describing how the phone system is configured appears at all.
- A rename appears as a new
nameorlabelon the same identifier or code, alongside the source record’supdatedAt. - A deactivated item that is still referenced stays, carrying its
status, because historical rows still point at it. Deactivation is not removal. - A missing item was deleted or is no longer referenced by any retained call. Drop it from your
lookup either way. When a call group, extension, or representative is deleted, historical rows
have their reference cleared — but that clearing does not advance a row’s
updatedAt, so its disappearance from this snapshot is the only signal you get, and only a fresh creation-time extraction shows the cleared reference on the rows themselves. - Every classification code resolves to a label. Each category and sentiment entry names the
sourcethat produced its label:phone_system_override,global_default, orfallback. A code with no current definition falls back to a deterministic label derived from the code itself and reportsstatus,score, andupdatedAtasnull, because no definition stands behind it.
A snapshot, trimmed to one member per family:
{
"generatedAt": "2026-09-11T19:34:54.337Z",
"callGroups": [
{
"id": "4b3d6f0c-9e85-40cc-8d71-6c0e2f5a8b14",
"name": "Sales Team",
"number": "120",
"status": "active",
"updatedAt": "2026-08-13T16:54:35.804Z"
}
],
"extensions": [
{
"id": "0f6a1c2e-71d3-4b19-8a10-2b0f4c9a5f22",
"name": "Sam Ortega",
"number": "101",
"status": "active",
"updatedAt": "2026-05-26T20:27:06.139Z"
}
],
"handlerEntries": [
{
"id": "0d9f2b6e-5a41-4c88-9b3d-2f6e8a1c4d70",
"name": "Sam Ortega",
"status": "active",
"updatedAt": "2026-07-02T11:18:44.201Z"
}
],
"categories": [
{
"code": "service_request",
"label": "Service Request",
"source": "phone_system_override",
"status": "active",
"score": null,
"updatedAt": "2026-06-19T09:31:07.550Z"
}
],
"overallSentiments": [
{
"code": "positive",
"label": "Positive",
"source": "global_default",
"status": "active",
"score": 1,
"updatedAt": "2026-04-02T13:22:18.004Z"
}
],
"employeeSentiments": [],
"customerSentiments": [
{
"code": "unrecognized_code",
"label": "Unrecognized Code",
"source": "fallback",
"status": null,
"score": null,
"updatedAt": null
}
]
}
Arrays are ordered by the stable key — id for the identifier-based families, code for the
classification ones — so two identical snapshots serialize identically and a diff shows only real
change. handlerEntries carry no number, and a category never carries a score. The
customerSentiments entry above is the fallback case: a code some retained call still references
with no definition behind it any more.
A label is never blank and never disappears.
This is a reporting projection rather than the call-group and extension resources, which keep their own scopes and their own fuller detail. How often you refresh is your choice; once per reporting run is usually enough.
Limits and the failures they produce
| Control | Value |
|---|---|
| Rows per page, metadata only | 100 maximum, 25 by default |
| Rows per page, with transcripts | 25 maximum |
| Response size, either operation | 5,500,000 bytes as encoded |
| Request rate, each operation | 120 requests per minute per key |
Request rate. The two reporting operations are metered separately from the key’s tier
allowance: 120 requests per minute, counted per key, for each operation. That budget belongs to the
key and is shared across every phone system the key covers, so iterating systems divides it rather
than multiplying it. Reporting requests also count toward the key’s overall tier allowance, so a
standard key can make at most 120 requests per minute across reporting and all other operations
combined. Spread historical backfills over time rather than sending parallel bursts.
Over the budget you get the standard 429 with code: "RATE_LIMITED" and
a retry_after. These two operations also answer with that same 429 when the rate limiter itself
cannot be consulted: everywhere else the API keeps serving in that case, but an unmetered bulk read
is refused. Back off and retry exactly as you would for an exhausted budget.
Response size. Both operations refuse a response above 5,500,000 bytes with 413 and
code: "EXPORT_TOO_LARGE". Nothing is ever truncated — you get a complete response or a complete
refusal. params carries actualBytes and maxBytes, and actualBytes is the encoded size of
the response as it would be transmitted. Escaping counts toward that budget, so a page dense with
quotation marks, newlines, or non-Latin text can exceed the limit while the JSON you would have
parsed looks visibly smaller. Size your requests against the reported actualBytes, never against
the length of a response you received.
A refusal, so you can branch on it without guessing at the shape:
{
"type": "https://errors.steerphones.example/export-too-large",
"title": "Response too large",
"status": 413,
"code": "EXPORT_TOO_LARGE",
"detail": "The response is too large to deliver. Retry with a smaller limit, or with includeTranscripts=false.",
"params": { "actualBytes": 6120448, "maxBytes": 5500000 },
"correlationId": "9f2c41ab",
"retryable": false
}
EXPORT_TOO_LARGE carries one of two params shapes, depending on which budget stopped the
response. Branch on which keys are present rather than assuming actualBytes:
| Refused because | params |
Means |
|---|---|---|
| The encoded response exceeded the transport budget | actualBytes, maxBytes |
The page was built, measured, and found too large. |
| The stored transcripts for the page were too large to fetch | guard, storedBytes, limitBytes, rowCount |
Refused before the bodies were read, so nothing was transferred. storedBytes is the size of the stored transcripts, not an encoded response size — do not compare it against maxBytes. |
Both are recoverable the same way: retry with a smaller limit, or with includeTranscripts=false.
Every error from these operations is this same RFC 9457 problem document, so one handler covers all
of them: branch on code, read params when present, and log correlationId — quoting it is the
fastest way for support to find your exact request. retryable tells you whether retrying the
identical request could ever succeed: false here, because the same page will be the same size
next time, and true for the 503 a dimensions snapshot returns when it exceeds its time budget.
Recovery depends on which refusal you got:
- An oversized report page is recoverable. Retry with a smaller
limit, or withincludeTranscripts=false. - A single transcript that overflows on its own —
limit=1, transcripts on, still 413 — cannot be delivered by this operation in version 1. That is terminal for including the transcript here, not for the traversal: replay the same cursor withincludeTranscripts=falseto take the row’s metadata and keep going. The call-log detail operation may still return that transcript as plain text, which is a much smaller representation. - An oversized dimensions snapshot is terminal. It is one complete replace-all snapshot with no page-size parameter, so there is no narrower request to retry and lowering a limit cannot help. Contact support. Your report rows stay usable meanwhile, because each already carries the identifiers, names, and labels it references.
Dimensions timing out. If assembling the snapshot runs past the time the request is allowed,
the dimensions operation answers 503 with code: "EXTERNAL_SERVICE_TIMEOUT" and retryable: true
rather than failing without explanation. A partial snapshot is never returned. Retry the request.
What one report row counts as
A row is one call log, and a single phone call can produce more than one — a call that rings a group and is answered by an extension, or that is forwarded onward, is recorded as more than one leg. The dashboard export has always worked this way, so a report and the export agree; but if you sum rows to count calls, you will count those calls more than once.
Group by callSid when you want calls rather than legs. Keep id as the key you upsert on, since
that is what stays unique and what the cursor traverses.
Dashboard export columns, column by column
The dashboard’s Call Log export and these report rows are rendered from the same underlying values, so every meaningful column has a field here:
| Export column | Report field |
|---|---|
| Call SID | callSid |
| Parent Call SID | parentCallSid |
| Direction | direction |
| Start Time (ISO) | startTime |
| End Time (ISO) | endTime |
| Date / Time (UTC) | Derived from startTime, or createdAt when the call never started |
| From | from |
| From Display | fromDisplay |
| From Number | fromNumber |
| To | to |
| To Display | toDisplay |
| To Number | toNumber |
| Forwarded From | forwardedFrom |
| Call State | status |
| Status | outcome |
| Business Outcome | callOutcomeCode |
| Is Converted | isConverted |
| Conversion Value | conversionValue |
| Outcome Source | outcomeSource |
| Category | category.label |
| Category Code | category.code |
| Overall Sentiment | overallSentiment.label |
| Overall Sentiment Score | overallSentiment.score |
| Employee Sentiment | employeeSentiment.label |
| Employee Sentiment Score | employeeSentiment.score |
| Customer Sentiment | customerSentiment.label |
| Customer Sentiment Score | customerSentiment.score |
| Sentiment Source | sentimentSource |
| Score | scorecardScore |
| Representative | representative.name, with representative.state |
| Representative Confidence | representative.confidence |
| Representative Source | representative.source |
| Duration (seconds) | durationSeconds |
| Duration (verbose) | Derived from durationSeconds |
| Recording | hasRecording |
| Recording SID | recordingSid |
| Recording Duration (seconds) | recordingDurationSeconds |
| Transcription Status | transcriptionStatus |
| Call Group | callGroup.name |
| Answered By Extension | answeredByExtension.name |
| Answered By Extension Number | answeredByExtension.number |
| From Extension | fromExtension.name |
| From Extension Number | fromExtension.number |
| Is Emergency | isEmergency |
| Is Edited | isEdited |
| Tags | tags[].name |
| Route Summary | routeSummary |
| Created At (ISO) | createdAt |
| Transcript | transcript.text, with includeTranscripts=true |
Columns that are formatting, not data
Three export columns are presentation the spreadsheet needs, derived from values the API already returns. The API returns the source value and leaves the rendering to you, so your report can use its own zone, locale, and wording:
- Date / Time (UTC) is
startTime— orcreatedAtfor a call that never started — rendered asYYYY-MM-DD HH:MM UTC. - Duration (verbose) is
durationSecondsrendered as45s,3m 20s, or1h 5m. - Representative renders
Unknownfor an analyzed call that matched nobody andN/Afor a call attribution never ran on. The API keeps those apart inrepresentative.state, which isidentified,unknown, ornot_analyzed, so you branch on a value instead of parsing a word.
conversionValue is a related case: it is returned as an exact decimal string rather than a JSON
number, so no precision is lost between the call and your ledger. Parse it with a decimal type.
Transcription Status reads differently here
This is the one column where the export and the API disagree on purpose, so check it before you treat a mismatch as a bug.
The export’s Transcription Status records what the last transcription attempt did. The API’s
transcriptionStatus records whether transcript text is retrievable right now. For a call whose
transcript was removed — by your retention settings or an explicit deletion — the export still
reads completed while the API reads unavailable.
Both are accurate about what they describe, and the API’s answer is the one that predicts what a
read returns: completed there means text is present in the response. If you reconcile a report
against a spreadsheet, expect this column to differ on exactly those calls and treat the API value
as authoritative for availability.
Store Name and other columns you add downstream
Some spreadsheets add a Store Name column after the export. It is not a Call Log value, and the
API does not add it: the Call Log contract stays the same for every customer. Derive it instead.
Every report row carries phoneSystemId, and GET /v1/customer/phone-systems returns the
identifier and name of every phone system the key covers. Fetch that list once per run and join on
the identifier. The join also keeps the column correct when a phone system is renamed, which a
copied string would not.
The same approach covers anything else your report wants: join a stable identifier the row already carries to data you can fetch, rather than waiting for a column.
Tags carry the snapshot that was applied
A row’s tags are the tags as they were applied to that call — name, group, and color as
they read then, plus the stable id, what applied it in source, and confidence for an
automatically applied tag. Match on id when you need the tag’s current definition.
Because that history is retained, a tag that any call in your retained history still carries cannot be deleted. Inactive is the retirement path: it stops the tag being applied to new calls, leaves it on the calls that already carry it, and keeps its name reserved in its group so you can rename or reactivate it later. See Organize calls with tags and tag groups.
Call analyses
Call analyses are the AI analysis results announced by call.analysis.completed. The list accepts
the same date window plus a filter on callSid or callLogId — exactly the two identifiers that
event carries — so the event-to-fetch step is a single filtered list call. The detail operation
takes the id from a list row.
An analysis carries a lifecycle status of pending, processing, completed, or failed;
overallScore (0–100) and analyzedAt stay null until the analysis completes. coachingTips
is the list of actionable suggestions the analysis produced. Requires calls:read.
Voicemails
The voicemail list returns the phone system’s voicemails newest first, bounded by the same
inclusive date window. Rows carry the caller, duration, the receiving mailbox (extensionId or
callGroupId), the listened and archived flags, and the recordingSid of the voicemail
audio. Deleted voicemails never appear, and their identifiers answer with the not-found shape.
The detail operation, by the voicemailId a voicemail webhook carries, adds transcribedText and
transcriptionStatus under the same four-value vocabulary as call logs — the voicemail transcript
rides the voicemail detail response.
Voicemail responses never contain audio. Follow the row’s recordingSid to the signed-URL
operation described below — which means an integration that plays voicemail audio needs
recordings:read granted alongside voicemails:read. Listing and reading voicemail metadata and
transcripts needs only voicemails:read.
Recordings and audio
The recording list and detail operations cover call recordings. Each row carries an explicit
readiness status:
ready— the audio has been stored and the signed-URL operation will return a URL now.processing— the audio is still landing after the call; retry shortly, or subscribe tocall.recording.availableand skip polling entirely.
A voicemail’s recordingSid does not resolve at the recording detail operation — voicemail
metadata is represented by the voicemail operations — and answers not-found there. Its audio,
however, is served the same way as call audio:
The signed-URL operation, GET …/recordings/{recordingSid}/url, returns short-lived access to the
audio of both call recordings and voicemails:
{
"url": "https://…",
"expiresAt": "2026-08-09T15:21:11.000Z"
}
- The URL stops working at
expiresAt, 15 minutes after issue. It is bearer access to the audio: do not store it, log it, or hand it to a browser you do not control — request a fresh one each time instead. - While the audio is still processing, the operation answers 409 with
code: "RECORDING_NOT_READY"andretryable: true— retry after a short delay. - An identifier that is absent, deleted, permanently unavailable, or outside the key’s grant answers the identical not-found shape.
All three recording operations require recordings:read.
From webhook to fetch
| Event received | Payload identifier | Fetch with |
|---|---|---|
call.ended |
callSid |
Call-log list filtered by callSid |
call.recording.available |
recordingSid |
Recording detail, then the signed audio URL |
call.transcription.completed |
callLogId, plus voicemailId for voicemails |
Call-log detail — or voicemail detail via voicemailId |
voicemail.created |
voicemailId |
Voicemail detail; audio via the signed-URL operation |
call.analysis.completed |
callSid and callLogId |
Call-analysis list filtered by either identifier |
The readiness events fire only once their artifact is genuinely fetchable, so a fetch triggered by
one does not need the retry handling above. A fetch triggered by call.ended does: the recording
and transcript usually finish after the call, which is exactly what processing means. Payload
shapes and delivery semantics are in Receive events with webhooks.
Artifacts can change after you read them
A read is a snapshot, not a promise. Recordings and transcripts can be redacted or deleted after
the fact — by retention rules or an explicit deletion — so a transcript that read completed can
later read unavailable, and a recording that returned audio yesterday can answer not-found
today. Mirror deletions if you copy artifacts into your own system, and treat a not-found on a
previously fetched identifier as removal rather than an error.
Paging through history
The standard list operations share one pagination contract — 1-indexed page, pageSize up to
100, a {data, total, page, pageSize, totalPages} envelope, deterministic newest-first ordering —
and one recommended backfill pattern. Both are specified in
API versioning and errors.
The call-log reporting collection is the documented exception. It pages by opaque cursor, ascending
by the timestamp you selected and then by identifier, over a window you bound yourself, and returns
{data, nextCursor, hasMore} with no total. Its rules are under
Traverse with the cursor.