Calls
List and inspect every call your agents handle: read transcripts and grades, export recordings and structured data, and report issues on calls that need review.
Every inbound call to your phone numbers, every call triggered from POST /v1/call, every browser mic session, and every widget call produces a call log record. These endpoints let you enumerate and inspect them, fetch recordings and transcripts, record AI quality grades, file issue reports, and export history.
Endpoints
| Method | Path | Description |
|---|---|---|
GET | /v1/calls | List calls (paginated, filterable) |
GET | /v1/calls/export | Bulk export up to 1,000 calls as CSV or JSON |
GET | /v1/calls/{call_id} | Retrieve a single call |
GET | /v1/calls/{call_id}/audio | Get a time-limited recording URL |
GET | /v1/consent-announcements/{asset_hash} | Stream the consent announcement that played on a call |
GET | /v1/calls/{call_id}/transcript | Fetch the transcript |
GET | /v1/calls/{call_id}/history | Fetch the full structured turn history |
GET | /v1/calls/{call_id}/grade | Fetch the latest AI quality grade |
POST | /v1/calls/{call_id}/grade | Run (or re-run) AI grading |
GET | /v1/calls/{call_id}/issue-reports | List issue reports for a call |
POST | /v1/calls/{call_id}/issue-reports | File an issue report |
POST | /v1/calls/{call_id}/listen | Mint a listen-only token for a live call |
POST | /v1/calls/{call_id}/whisper | Send private text guidance to the agent on a live call |
POST | /v1/calls/{call_id}/promote-to-scenario | Turn a real call into a regression test scenario |
Call object
{
"call_id": 987654321,
"agent_id": 12,
"agent_variant_id": null,
"agent_variant_label": null,
"direction": "inbound",
"from_number": "+14155550199",
"to_number": "+15551234567",
"start_time": "2026-04-20T18:24:10.113Z",
"end_time": "2026-04-20T18:25:04.822Z",
"duration_seconds": 54,
"status": "completed",
"end_reason": "caller_hangup",
"recording_url": null,
"recording_started_at": "2026-04-20T18:24:12.501Z",
"consent_evidence": {},
"is_simulation": false,
"is_test_call": false,
"is_scenario_call": false,
"prompt_snapshot": "You are a helpful support agent for Acme Ops…",
"tool_snapshot": [],
"cloud_run_execution_name": null,
"livekit_room_name": "call-987654321",
"livekit_room_sid": "RM_xxxxxxxxxxxx",
"product": "spark",
"voice": "john",
"billable_minutes": 1.25,
"billing_total_cents": 3,
"billed_at": "2026-04-20T18:25:05.101Z",
"grade_score": 92,
"grade_status": "completed",
"grade_updated_at": "2026-04-20T18:25:11.002Z",
"call_outcome": "success",
"issue_count": 0,
"critical_issue_count": 0
}| Field | Type | Description |
|---|---|---|
call_id | integer | Server-assigned call id (also the key in every other endpoint on this page) |
agent_id | integer | null | Agent that handled the call |
agent_variant_id | integer | null | Split-testing variant selected for this call; null when no split test ran |
agent_variant_label | string | null | Label of that variant, snapshotted at call time (survives variant deletion) |
direction | string | inbound, outbound, test (test call in the agent builder), or web (web call from the embeddable widget) |
from_number | string | E.164 caller number. Test calls (direction: "test") use an internal identifier |
to_number | string | E.164 destination number |
start_time, end_time | timestamp | null | ISO 8601 UTC. end_time is null while status="in_progress" |
duration_seconds | integer | null | Derived |
status | string | in_progress, completed, or failed |
end_reason | string | null | Free-form reason e.g. caller_hangup, agent_end, timeout, ai_transfer |
recording_url | string | null | Storage URL when set — not guaranteed playable. Use /audio for a signed, playable URL |
recording_started_at | timestamp | null | When the recorder actually began capturing (1–3 s after start_time). Transcript start_ms/end_ms are anchored to this when present; null on older calls |
is_simulation | boolean | True if the call was produced by a simulation run |
is_test_call | boolean | Deprecated alias for is_simulation; retained during the transition |
is_scenario_call | boolean | True only for runs launched from a scenario batch — ad-hoc simulations stay false |
prompt_snapshot | string | null | The user-authored agent prompt in effect at call time |
tool_snapshot | array | null | The tool definitions the agent had at call time |
cloud_run_execution_name | string | null | Internal identifier for the worker that ran the call. Useful only when corresponding with support; may be null |
livekit_room_name, livekit_room_sid | string | null | Internal identifiers for the underlying media room. Do not build on these; they exist for supervision/support tooling |
product, voice | string | null | The Agent config in effect at call time |
billable_minutes | number | null | Minutes billed, rounded to the nearest quarter minute (15-second increments, minimum 0.25). Straight-to-voicemail calls still report their actual metered minutes here, but the charge is capped at one minute at the plan rate. |
billing_total_cents | integer | null | Final bill in USD cents |
billed_at | timestamp | null | When billing finalised |
grade_score | integer | null | 0–100 quality score from the latest AI grading |
grade_status | string | null | pending, completed, or failed |
grade_updated_at | timestamp | null | Last grading run |
call_outcome | string | null | From the latest grade: success, failure, unknown, or no_conversation |
issue_count | integer | Total issue reports filed against this call |
critical_issue_count | integer | Subset with severity critical |
consent_evidence | object | Consent-announcement evidence record — see Consent evidence. {} when no announcement played |
Consent evidence
Calls on agents with the consent announcement enabled carry a read-only
consent_evidence object recording exactly what played and what the caller
did afterwards:
| Field | Type | Description |
|---|---|---|
asset_version, asset_hash | string | Identity of the exact audio asset that played; asset_hash keys the consent announcement audio endpoint |
language, announcement_text | string | Announcement language and verbatim text |
playback_started_at, playback_completed_at, capture_started_at | timestamp | Ordered: playback completes before caller-audio capture is enabled |
continuation_event | object | absent | First caller activity after the announcement completed: {event_type: "speech" | "dtmf", at, participant_identity, dtmf_digit?}. Only actual confirmed speech or a keypad press counts — a merely connected line never produces this event |
objection_events | array | absent | Detectable objection/withdrawal signals, each {event_type, at, participant_identity, dtmf_digit?, seconds_after_playback_completed?} with event_type one of dtmf_during_announcement, hangup_during_announcement, hangup_after_announcement_without_activity (the latter only for hangups shortly after the notice with no caller activity; a later silent hangup is not recorded as an objection) |
continuation_event and objection_events document caller behavior around
the notice; they are not proof the caller heard the announcement and are not
a substitute for affirmative (press-1) consent where a jurisdiction requires
it.
List calls
curl 'https://api.thunderphone.com/v1/calls?limit=50' \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"page = requests.get(
"https://api.thunderphone.com/v1/calls",
headers={"Authorization": "Bearer sk_live_YOUR_API_KEY"},
params={"limit": 50, "direction": "inbound"},
).json()
for call in page["results"]:
...const url = new URL("https://api.thunderphone.com/v1/calls");
url.searchParams.set("limit", "50");
const page = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.THUNDERPHONE_API_KEY}` },
}).then((r) => r.json());Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Page size (1–200) |
offset | integer | 0 | |
agent_id | integer | Filter by agent | |
direction | string | inbound, outbound, test (test call in the agent builder), web (web call from the embeddable widget). Legacy mic and widget values remain accepted as filters during migration. Historical webhook data may still contain those old values. | |
status | string | in_progress, completed, failed | |
phone_number | string | Matches either side of the call (from_number or to_number); formatted numbers are normalized before matching | |
from_number | string | E.164 | |
to_number | string | E.164 | |
start_date | ISO 8601 | Earliest start_time | |
end_date | ISO 8601 | Latest start_time |
Paginated response
{
"results": [ /* Call objects */ ],
"total": 1234,
"limit": 50,
"offset": 0
}Use offset + limit to page forward; check total to know when
you've reached the end.
Export calls
Bulk-download up to 1,000 of the most recent calls as either CSV (metadata only) or JSON (includes transcripts). The export honors the same filter parameters as List calls, so what you export matches what you filtered.
curl 'https://api.thunderphone.com/v1/calls/export?export_format=csv' \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-o calls.csvcurl 'https://api.thunderphone.com/v1/calls/export?export_format=json&status=completed&start_date=2026-07-01T00:00:00Z' \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-o calls.jsonQuery parameters
| Param | Required | Description |
|---|---|---|
export_format | yes | csv or json |
agent_id | Filter by agent | |
direction | Same values as List calls (inbound, outbound, test, web; legacy mic/widget accepted) | |
status | in_progress, completed, failed | |
phone_number | Matches either side of the call (from_number or to_number); formatted numbers are normalized before matching | |
from_number | E.164 | |
to_number | E.164 | |
start_date | ISO 8601 — earliest start_time | |
end_date | ISO 8601 — latest start_time |
limit/offset are not accepted — the export always returns the most
recent matches up to its fixed cap.
Response
The response includes a Content-Disposition: attachment header with a
dated filename (calls-export-YYYY-MM-DD.csv).
Exports cap at the 1,000 most recent calls matching the filters. When more calls match than the cap allows, the response carries truncation signals so you can detect an incomplete export:
| Signal | Where | Meaning |
|---|---|---|
X-Truncated | header (CSV and JSON) | true when the filtered match count exceeds the row cap, else false |
X-Total-Count | header (CSV and JSON) | Untruncated count of calls matching the filters |
truncated | JSON body field | Same signal as X-Truncated, for JSON consumers |
For a truncated window, narrow the date range (start_date/end_date)
and export in slices, or use the list endpoint with offset pagination.
Retrieve a call
curl https://api.thunderphone.com/v1/calls/987654321 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"Returns 200 OK with a Call object, or 404 if the
call does not belong to your organization.
Get recording audio
Returns a short-lived signed URL to the MP3 recording on Google Cloud Storage. Stream the URL directly to your audio player — do not store it (it expires).
curl https://api.thunderphone.com/v1/calls/987654321/audio \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{ "url": "https://storage.googleapis.com/thunderphone-recordings/..." }| Status | Condition |
|---|---|
200 | Recording exists, URL returned |
404 | Call or recording not found |
Get caller audio
GET /v1/calls/{call_id}/caller-audio?ref={ref}
Streams what the caller actually said for one turn of the conversation.
ref comes from a transcript entry's caller_audio_refs, present on caller
turns whose audio was captured.
| Status | Condition |
|---|---|
200 | Audio stream returned |
404 | Call, turn, or audio not found |
Get consent announcement audio
GET /v1/consent-announcements/{asset_hash}
Streams the exact consent-announcement asset that played at the start of a
call, as a 48 kHz mono WAV. Calls that played an announcement carry a
consent_evidence object (asset hash, announcement text, playback and
capture timestamps); pass its asset_hash here to retrieve the audio whose
content hash matches it byte-for-byte. This endpoint is the programmatic
evidence-access path; the stored call recording itself also begins with
this announcement — it is muxed in at recording finalization, and
consent_evidence then carries recording_announcement_offset_seconds /
recording_announcement_duration_seconds marking where the prepended
segment sits inside the recording. When additional participants joined
mid-call (for example a warm-transfer target), the same object also carries
a legs array — one entry per joining leg with the leg's identity, join
time, its own playback/capture timestamps for the same content-addressed
asset, and post-notice continuation details.
Authorization is by evidence linkage: the hash must appear in the
consent_evidence of a call your organization can access. Responses are
never cacheable (Cache-Control: no-store), and the endpoint is
rate-limited per user.
curl https://api.thunderphone.com/v1/consent-announcements/f1d6b49f4ac8f391cfc933591f53415bc71083a62d723acb4de5606cea3d4d73 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
--output consent-announcement.wav| Status | Condition |
|---|---|
200 | WAV audio stream returned |
404 | Hash malformed, not linked to any of your org's calls, or the asset is no longer cached |
429 | Rate limit exceeded — retry later |
Get transcript
Simplified, user-facing transcript (role-tagged speech only; no system
events). For the full structured history including function calls,
interruptions, and latency metrics use /history.
curl https://api.thunderphone.com/v1/calls/987654321/transcript \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"call_id": 987654321,
"transcripts": [
{
"role": "user",
"content": "Hi, I have a question about my policy.",
"start_ms": 1200,
"end_ms": 3400,
"caller_audio_refs": ["…", "…"]
},
{
"role": "agent",
"content": "Of course — could I have your member id?",
"start_ms": 3800,
"end_ms": 6100,
"ttfa_ms": 412
}
]
}| Field | Type | Description |
|---|---|---|
role | string | user, agent, tool_call, tool_response, or system |
content | string | Human-readable content. For tool_call/tool_response turns this is a JSON string |
start_ms, end_ms | integer | Offset in milliseconds, anchored to the recording when recording_started_at is set. Omitted when timing could not be reconstructed |
ttfa_ms | integer | Time-to-first-audio for the first agent turn of a response. Present on agent turns only |
caller_audio_refs | array of string | Opaque references to the audio of what the caller said, in the order they were spoken. Pass each to /caller-audio to fetch it. Present on user turns only, and omitted entirely — never null — when no audio was captured |
Caller audio is always the call audio as received, never a noise-suppressed version, so re-listening to two different turns is always comparing like with like. A long caller turn can span several recordings; fetch them in the order given and concatenate.
| Query param | Description |
|---|---|
live=true | Use a lenient serializer that includes not-yet-resolved turns — for real-time display while the call is still in progress. Caller turns carry caller_audio_refs here too, so audio can be fetched while the call is still running |
Get history
Full structured turn history. Use this when you need raw function-call arguments, interruption markers, ack-prompt annotations, or first-byte latencies.
curl https://api.thunderphone.com/v1/calls/987654321/history \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"Returns an array of typed entries. See the ThunderPhone call-history
schema reference
for the full shape — the top-level entries include span, completion,
patch, function_call, function_response, interrupt, and
acknowledgement.
AI call grading
Get latest grade
curl https://api.thunderphone.com/v1/calls/987654321/grade \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"id": 88,
"call_id": 987654321,
"status": "completed",
"score": 92,
"call_outcome": "success",
"outcome_reasoning": "The caller's policy question was answered fully and confirmed.",
"summary": "Caller asked about their policy and got a full answer…",
"rubric_breakdown": { "accuracy": 95, "tone": 90 },
"detected_issues": [],
"grader_model": "heuristic-v1",
"grader_version": "v1",
"error_message": null,
"graded_at": "2026-04-20T18:25:11.002Z",
"created_at": "2026-04-20T18:25:11.002Z",
"updated_at": "2026-04-20T18:25:11.002Z"
}| Field | Type | Description |
|---|---|---|
id | integer | Grade row id |
call_id | integer | |
status | string | pending, completed, or failed |
score | integer | null | 0–100 |
call_outcome | string | success, failure, unknown, or no_conversation |
outcome_reasoning | string | Why the grader chose that outcome |
summary | string | One-paragraph call summary |
rubric_breakdown | object | Per-criterion scores |
detected_issues | array | Machine-detected issues; completed grades sync these into issue reports with source: "system" |
grader_model, grader_version | string | Which grader produced the row |
error_message | string | null | Set when status="failed" |
graded_at | timestamp | null | When grading completed |
created_at, updated_at | timestamp |
Returns 200 OK with the latest grade row, or 404 if the call has
not been graded.
Run grading
Grade the call. Grading runs synchronously within the request. If a
completed grade already exists it is returned as-is unless you pass
force=true.
Any request that would invoke the grader — a forced regrade, or a request whose latest grade is missing, pending, or failed — must first claim a per-call cooldown. The standard cooldown is 15 minutes. If the latest grade is a retryable failure, the cooldown is 90 seconds so a retry can recover sooner. At most one new grading run can be claimed per window.
curl -X POST 'https://api.thunderphone.com/v1/calls/987654321/grade?force=true' \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"A request inside the active cooldown returns 429 Too Many Requests. The
Retry-After response header and retryAfterSeconds body field contain the
same number of seconds to wait:
{
"detail": "This call was recently submitted for grading. Please retry later.",
"retryAfterSeconds": 90
}| Status | Condition |
|---|---|
200 | An existing completed grade was reused (no force) |
201 | A new grade completed |
429 | The per-call cooldown is active; use Retry-After or retryAfterSeconds before retrying |
502 | Grading failed — the returned grade row has status="failed" and an error_message; retry later |
Issue reports
Issue reports let your team (or automated grading) flag specific calls for review. Reports have a severity and optional title/description and appear in the org-wide Issue Reports feed.
List reports for a call
curl https://api.thunderphone.com/v1/calls/987654321/issue-reports \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"Returns an array of Issue Report objects — see Issue Reports.
File a report
curl -X POST https://api.thunderphone.com/v1/calls/987654321/issue-reports \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Agent paused too long",
"description": "Five second silence before responding to the main question.",
"severity": "warning"
}'| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | 1–255 chars |
description | string | no | Free-form. Defaults to "" |
severity | string | no | info, warning, or critical. Defaults to warning |
source | string | no | user (default) or system |
timestamp_seconds | integer ≥ 0 | null | no | Offset into the call where the issue occurred |
detected_issue_ref | string | no | Up to 128 chars — correlates the report with a grader-detected issue |
metadata | object | no | Arbitrary JSON. Defaults to {} |
Returns 201 Created with the Issue Report object.
To update a report's status later, use the
issue-reports endpoints
(reports are addressed by trace_id).
Live call supervision
Both endpoints work only while status="in_progress"; once the call
ends they return 409 Conflict with {"detail": "Call is not live."}.
The dashboard's Live page is built on the same primitives.
Listen in
Mints a hidden, subscribe-only token for the call's media room. The listener is invisible: the agent, the caller, and the recording are unaffected.
curl -X POST https://api.thunderphone.com/v1/calls/987654321/listen \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"token": "eyJhbGciOi...",
"room_name": "call-987654321",
"server_url": "wss://livekit.thunderphone.com"
}Connect to server_url with a LiveKit client SDK using token and
subscribe to the room's audio. The token is valid for up to 2 hours;
the session ends when the call's room closes.
| Status | Condition |
|---|---|
200 | Token minted |
404 | Call not found |
409 | Call is not live |
Whisper to the agent
Sends private text guidance that only the agent hears — the caller is never aware of it. The agent weaves the guidance into its next turns. Use it to coach an agent through an unusual situation mid-call.
curl -X POST https://api.thunderphone.com/v1/calls/987654321/whisper \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Offer the caller a 10% discount if they mention cancelling."}'| Field | Type | Required | Description |
|---|---|---|---|
text | string | yes | 1–500 chars of operator guidance |
{ "status": "sent" }| Status | Condition |
|---|---|
202 | Guidance delivered to the live session |
404 | Call not found |
409 | Call is not live (including the brief window after hang-up) |
502 | Delivery to the call runtime failed |