Skip to content
Esc
↑↓navigate↵open⌘Jpreview
On this page

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:

  1. List a resource to build a compact inventory and collect stable IDs.
  2. Fetch a detail row when you need hardware, membership, routing, or playback information.
  3. Follow each typed reference using its own phoneSystemId, resource type, and id.

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 to call.transcription.completed instead.
  • 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:read across the whole phone system. A key whose calls:read is 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 the calls:read you 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 and recordings: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) or updatedAt. It selects both the filter and the ordering.
  • dateFrom and dateTo — both required, ISO 8601 UTC. The window is half-open: dateFrom is included and dateTo is 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, and dateTo. 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 id and 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 — true or false, defaulting to false.

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:

  • completed describes 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 at completed.
  • 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 unavailable and returns no transcript whatever else is stored beside it, and a row whose text has no usable structure returns the text with an empty turns array.

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=true and no stored text — "transcript": null. The row is still returned in full.
  • includeTranscripts=false — the transcript property 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 name or label on the same identifier or code, alongside the source record’s updatedAt.
  • 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 source that produced its label: phone_system_override, global_default, or fallback. A code with no current definition falls back to a deterministic label derived from the code itself and reports status, score, and updatedAt as null, 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 with includeTranscripts=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 with includeTranscripts=false to 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 — or createdAt for a call that never started — rendered as YYYY-MM-DD HH:MM UTC.
  • Duration (verbose) is durationSeconds rendered as 45s, 3m 20s, or 1h 5m.
  • Representative renders Unknown for an analyzed call that matched nobody and N/A for a call attribution never ran on. The API keeps those apart in representative.state, which is identified, unknown, or not_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 to call.recording.available and 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" and retryable: 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.