Open in
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/party-numbers | Phone numbers seen on your calls, for filter autocomplete |
GET | /v1/calls/report-summary | Call-report numbers for a date range and the previous period of the same length |
GET | /v1/calls/{call_id} | Retrieve a single call |
DELETE | /v1/calls/{call_id} | Permanently delete a call and its content (admin) |
POST | /v1/calls/bulk-delete | Permanently delete up to 100 calls (admin) |
POST | /v1/calls/bulk-delete/preview | Count what deleting up to 100 calls would also remove (admin) |
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 |
POST | /v1/calls/{call_id}/extract | Re-run structured data extraction |
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": "user_hangup",
"outbound_dial_sip_status": null,
"outbound_dial_sip_reason": "",
"outbound_dial_sip_kind": null,
"recording_url": null,
"recording_started_at": "2026-04-20T18:24:12.501Z",
"recording_stopped_at": null,
"recording_stopped_reason": null,
"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",
"transfer_outcome": null,
"transfer_bridge_kind": null,
"transfer_destination": "",
"transfer_sip_status": null,
"transfer_bridge_seconds": null,
"transfer_bridge_total_cents": null,
"voip_connection_id": null,
"grade_score": 92,
"grade_status": "completed",
"grade_updated_at": "2026-04-20T18:25:11.002Z",
"call_outcome": "success",
"extracted_data": {
"status": "completed",
"fields": {
"customer_name": "Alex Morgan",
"appointment_date": "2026-04-23"
},
"evidence": {
"customer_name": {
"quote": "My name is Alex Morgan",
"speaker_role": "caller",
"turn_index": 4
},
"appointment_date": {
"quote": "April 23 works for me",
"speaker_role": "caller",
"turn_index": 7
}
},
"verification": "verified",
"field_reasons": {},
"schema_version": "92850758e231a3c95a...",
"schema_snapshot": [
{ "key": "customer_name", "type": "string", "description": "Caller's full name" },
{ "key": "appointment_date", "type": "date", "description": "Requested appointment date" }
],
"extracted_at": "2026-04-20T18:25:09.120Z"
},
"issue_count": 0,
"critical_issue_count": 0,
"summary": "Alex Morgan called to book a follow-up visit and confirmed Thursday, April 23.",
"summary_status": "completed",
"report": {
"status": "completed",
"outcome_key": "appointment_set",
"outcome_label": "Appointment set",
"outcome_color": "green",
"outcome_reasoning": "A follow-up visit was booked on the call.",
"needs_attention": false,
"attention_reason": "",
"attention_note": "",
"attention_turn_index": null,
"attention_time_seconds": null,
"caller_name": "Alex",
"caller_is_new": false,
"call_reason": "Book a follow-up visit",
"one_line_summary": "Alex, follow-up visit, booked Thu Apr 23",
"caller_frustrated": false,
"frustration_cause": "",
"caller_experience_note": "Alex was comfortable talking to the assistant.",
"hangup_stage": "",
"handled_start_to_finish": true,
"appointment_set": true,
"appointment_new_client": false,
"transfer_result": "not_attempted",
"booking_result": "succeeded",
"detail_capture_trouble": false,
"generated_at": "2026-04-20T18:25:11.002Z"
}
}| 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 | Why the call ended, e.g. user_hangup, ai_hangup, voicemail_hangup, max_duration, ai_transfer, or caller_blocked for an inbound call refused by your blocklist |
outbound_dial_sip_status | integer | null | For an outbound call that never connected: the SIP status the carrier answered the dial with, e.g. 403 when it refused the call, 486 busy, 480 no answer. null when no carrier answered (the call failed before reaching one) and for every other call |
outbound_dial_sip_reason | string | The carrier's own reason text with outbound_dial_sip_status, e.g. Account is disabled D17; "" when the carrier gave none or outbound_dial_sip_status is null. Credential-like text in it is replaced with [hidden] |
outbound_dial_sip_kind | string | null | What outbound_dial_sip_status means: refused (the carrier refused the call), busy, no_answer or declined. null when outbound_dial_sip_status is null |
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 |
recording_stopped_at | timestamp | null | When the recording was stopped before the call ended; nothing after this moment is in the recording. null when the recording ran to the end of the call, on calls that were not recorded, and on calls from before this field was added |
recording_stopped_reason | string | null | Why the recording stopped early. warm_transfer: it stops just before a warm transfer calls the destination, which never hears a recording notice, and stays off even if the transfer fails and the caller returns to the agent. Treat other values as informational; more may be added. null when recording_stopped_at is null |
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 |
transfer_outcome | string | null | How the call's transfer ended: refer_accepted, bridged, bridge_unanswered or failed (see call.transfer_completed). null for a call without a transfer, and until the outcome arrives, which is after the call ends |
transfer_bridge_kind | string | null | For a bridged transfer: cold_fallback (the carrier refused a cold transfer and ThunderPhone connected the call itself) or warm (a warm transfer's two people after the agent left). null otherwise |
transfer_destination | string | The number the call was transferred to; "" for a call without a transfer |
transfer_sip_status | integer | null | The carrier's SIP status when it refused a cold transfer (403, 405 or 501); null when it did not |
transfer_bridge_seconds | integer | null | Bridged time billed, in seconds; null until the bridge ends, and for calls without one |
transfer_bridge_total_cents | integer | null | What the bridge cost in USD cents after any free call credit. It is billed on its own, not included in billing_total_cents; null until the bridge ends |
voip_connection_id | integer | null | The VoIP connection the call's number is imported through now (a number re-imported on another connection reports the new one, also on older calls); null for a ThunderPhone number, and once the number or its connection is deleted. When that carrier refuses cold transfers, turn them on for the connection |
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 |
extracted_data | object | null | Structured post-call extraction result. Lists include the lightweight status, fields, and schema_version; call detail also includes evidence (the exact structurally checked quote, at most 1,000 characters, plus speaker role and turn index per non-null field), verification, field_reasons, schema_snapshot, error_message, and extracted_at. Longer model-returned evidence quotes are rejected instead of truncated. verified means every candidate received exactly one valid independent verdict. unavailable covers failed, timed-out, skipped, malformed, or partial verification; valid partial verdicts still apply, while candidates without one valid verdict are nulled. Evidence is null whenever its field is null; the whole object is null when no extraction exists |
summary | string | null | Plain-language post-call summary (two to four sentences, at most 2,000 characters) when the agent has call_summary_enabled and generation succeeded; null otherwise. Present in both list and detail responses. Generated in the post-call extraction step, so when the call-complete webhook was sent without waiting for extraction this is where the summary appears afterwards |
summary_status | string | null | completed; failed (the model call failed or ran out of time; if the post-call step is retried, for example because extraction also failed, the summary is generated again); exhausted (the post-call step was retried three times without producing one); skipped (the call ended within 30 seconds with no caller speech, or no transcript was available); or skipped_recording_disabled. null when the agent had summaries turned off for this call |
issue_count | integer | Total issue reports filed against this call |
critical_issue_count | integer | Subset with severity critical |
report | object | null | The call report: what happened, who called, whether someone should follow up, and how the caller felt. null until the grade that produces it has landed (usually a few minutes after the call) |
steps_trace | array | null | The order the call moved through its agent's steps and handoffs: each entry has t_ms (milliseconds since the call started), agent (the agent's key, a plus its ID), step (the step ID, or null on a handoff to an agent without steps) and via (start, model, condition, on_enter or handoff). null for calls whose agent has no steps or handoffs, and in list responses (read it from a single call) |
collected_values | object | null | The values the agent collected in its steps, keyed by collect field name, plus <tool>_result for each step's on_enter_tool result. null for calls whose agent has no steps or handoffs, for calls whose content is not retained (recording turned off), and in list responses |
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 | |
hours | string | business or after_hours, judged in the organization's reporting time zone and business hours, or the agent's own when it has them. Without a schedule every call is business hours | |
needs_attention | boolean | 1 keeps only calls whose report flags a follow-up | |
outcome_key | string | Comma-separated report outcome keys (for example appointment_set,callback_requested) | |
exclude_test_numbers | boolean | 1 drops calls from or to the organization's excluded test numbers | |
attention_first | boolean | 1 sorts flagged calls to the top (newest first within each group) instead of pure recency | |
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 | |
exclude_test_calls | boolean | false | true leaves out test traffic: agent-builder test calls (and their SIP legs), simulation runs, and builder Talk sessions (direction=test). Web widget calls are kept. Values other than true or false (case-insensitive) return 400. |
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.
Call report
A call's report is the customer-facing read of what happened. It is
null until grading writes one. For a conversation, the full grade writes
the report in the same model pass. When recording is enabled, calls that
end before a conversation and calls with no transcript get a basic report
without a model pass. An early heuristic grade can leave report as
null. While batch grading is queued, report.status may be pending;
its fields are placeholder defaults, not findings, so read them only when
status is completed. Calls made with recording disabled get a report
with status skipped and no findings. If full grading fails, a report
may stay null or pending; that does not mean the call needs no
follow-up.
The outcome comes from the agent's
outcome_labels (Success /
Failure unless the agent defines its own list). The agent's
report_instructions ask the grader how to write one_line_summary,
call_reason, caller_experience_note and attention_note. For example,
you can ask it to omit health details. These are AI writing instructions,
not guaranteed redaction. They do not change the transcript, recording or
the separate Call object's summary.
| Field | Type | Description |
|---|---|---|
status | string | completed once written; pending while batch grading is queued (fields are placeholders); skipped when recording was disabled for the call (no findings) |
outcome_key | string | The configured key the grader picked; "" when no configured outcome applies |
outcome_label | string | The picked label. Quick hang-ups show No conversation; "" when no label is available |
outcome_color | string | The picked label's colour: green / red for the platform Success / Failure, the business's choice for its own labels (gray when unset), "" when no outcome applies |
outcome_reasoning | string | One sentence on why that label fits |
needs_attention | boolean | Someone at the business should follow up or review the call |
attention_reason | string | urgent, caller_frustrated, asked_for_person, agent_did_not_know, detail_capture_trouble, booking_failed, transfer_failed, or "" |
attention_note | string | One sentence on what the follow-up is about |
attention_turn_index, attention_time_seconds | integer | null | The transcript turn and recording second where the reason became apparent |
caller_name | string | As the caller gave it; "" when never stated |
caller_is_new | boolean | null | New (true), existing (false), or unknown |
call_reason | string | Why the caller called. The grader aims for at most eight words; the value is capped at 200 characters |
one_line_summary | string | A receptionist-style summary, e.g. "Jack, turning 65, booked Tue 2pm". The grader aims for under 120 characters; the value is capped at 240 |
caller_frustrated | boolean | Whether the caller showed frustration |
frustration_cause | string | When caller_frustrated is true, one of long_waits, repeated_questions, misheard_details, could_not_help, wanted_a_person, did_not_want_ai, or other; otherwise "" |
caller_experience_note | string | How the caller seemed to feel about the call, including whether they were fine talking to an AI |
hangup_stage | string | An early caller hang-up: at_greeting, mid_conversation, or after_ask (after the agent asked for information or made a request). "" when no early hang-up was identified, including a normal goodbye |
handled_start_to_finish | boolean | null | Fully handled on the call with nothing left for the business to do |
appointment_set, appointment_new_client | boolean, boolean | null | An appointment, booking or reservation was confirmed; whether it was for a new client |
transfer_result, booking_result | string | On a completed report: not_attempted, succeeded, or failed. Pending and skipped reports use "" |
detail_capture_trouble | boolean | The agent struggled to get an email, name, number or other detail right |
generated_at | timestamp |
The same object rides on the call.graded event.
Report summary
GET /v1/calls/report-summary
Returns the five call-report numbers shown on dashboard Home and in each agent's Calls tab. It compares the selected date range with the previous period of the same length. Counts always leave out the organization's excluded test numbers.
| Param | Type | Default | Description |
|---|---|---|---|
start_date, end_date | YYYY-MM-DD or ISO 8601 | last 30 days | Bare dates select whole local days in the reporting time zone, both ends included. ISO timestamps are exact: the start is included, the end is not. At most 366 days |
agent_id | integer | Filter by agent | |
direction | string | inbound, outbound, test, or web. Historical mic and widget rows can be queried by those literal legacy values | |
hours | string | business or after_hours |
{
"period": { "start": "2026-08-25T07:00:00Z", "end": "2026-09-24T07:00:00Z" },
"previous_period": { "start": "2026-07-26T07:00:00Z", "end": "2026-08-25T07:00:00Z" },
"timezone": "America/Los_Angeles",
"has_business_hours": true,
"excluded_numbers": 2,
"metrics": {
"calls_handled": { "value": 212, "previous": 187, "after_hours": 61, "after_hours_previous": 48, "good_direction": "up" },
"handled_start_to_finish": { "value": 150, "previous": 121, "good_direction": "up" },
"went_badly": { "value": 19, "previous": 26, "breakdown": { "hung_up": 9, "detail_capture_trouble": 5, "booking_failed": 3, "transfer_failed": 2 }, "good_direction": "down" },
"caller_experience": { "value": 171, "previous": 140, "conversations": 180, "frustrated": 9, "frustrated_previous": 14, "top_cause": "wanted_a_person", "top_cause_count": 4, "causes": [ { "cause": "wanted_a_person", "count": 4 } ], "good_direction": "up" },
"appointments_set": { "value": 64, "previous": 51, "new_clients": 22, "new_clients_previous": 15, "good_direction": "up" }
},
"needs_attention": { "value": 14, "previous": 20, "reasons": [ { "reason": "caller_frustrated", "count": 6 } ] },
"success_rate": { "graded": 198, "succeeded": 171, "graded_previous": 170, "succeeded_previous": 139 },
"outcomes": [ { "key": "appointment_set", "label": "Appointment set", "count": 64 } ],
"agents": [
{ "agent_id": 12, "agent_name": "Front desk", "calls": 140, "after_hours": 40, "handled": 101, "went_badly": 11, "frustrated": 5, "no_frustration": 118, "conversations": 123, "appointments": 48, "appointments_new": 17, "needs_attention": 9, "graded": 132, "succeeded": 116, "reported": 136 }
],
"reported": 205,
"pending_reports": 7
}good_direction says which way is good for each metric; colour arrows by
it, not by their sign (fewer calls that went badly is green).
Every report metric counts answered calls only: non-rejected inbound and
web calls, plus outbound calls once the other side answered. Browser tests
and simulations are left out, matching the Home call-volume chart.
calls_handled is that count.
caller_experience.value counts judged conversations with no frustration;
frustrated and frustrated_previous count frustrated callers (the
dashboard shows those). went_badly.value counts calls with a hang-up
mid-conversation or after the ask, trouble getting a detail right, a failed
booking or a failed transfer; one call can fall in several breakdown
buckets. reported counts calls with a completed report and
pending_reports the answered calls still waiting for one (a skipped
report is not pending). excluded_numbers is the number of configured
test numbers. It is not a count of excluded calls, and it includes
configured numbers with no calls in this date range. agents is
the same breakdown per agent for the current period, busiest first; an
agent with its own business_hours
is judged by them for after-hours.
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. Both formats carry the report columns
(outcome, needs_attention, attention_reason, caller_name,
caller_is_new, call_reason, summary, caller_frustrated,
hangup_stage, appointment_set, ...), blank until the call has a
completed report.
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 | |
exclude_test_calls | true or false (default). Same behavior as List calls: true leaves out test calls and simulations and keeps widget calls |
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.
List party numbers
Distinct phone numbers that have appeared on either side of your
organization's calls, most recently seen first. Use it to autocomplete
the from_number / to_number filters of List calls —
it includes external callers, not only numbers you own. Placeholder
parties for browser, widget and test sessions are omitted.
curl 'https://api.thunderphone.com/v1/calls/party-numbers?q=631456' \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"Query parameters
| Param | Required | Description |
|---|---|---|
q | Digit search. Punctuation is ignored on both sides, so (631) 456 matches +16314566068 | |
limit | Maximum numbers to return (default 50, max 200) |
Response
{
"numbers": ["+16314566068", "+15137061124"]
}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.
Delete a call
Permanently deletes one call and its content. Only owners and admins can
delete calls. An API key acts with the role of the user who created it, so
a key created by a member gets 403.
curl -X DELETE https://api.thunderphone.com/v1/calls/987654321 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"Deleting a call removes:
- the recording and every other stored audio file for the call
- the transcript and the full turn history
- the summary, call report, extracted data and AI grades
- issue reports filed on the call
- stored copies of webhook events about the call
- validation examples made from the call, with their copied turns and caller audio
Your billing history keeps the charge for the call (amount, billed minutes and call ID) without any call content, so balances and invoices do not change. Test scenarios created from the call and text messages sent during it are separate records and are not deleted. To see how many validation examples a deletion would remove before you delete, use Preview a deletion.
The call disappears from the API and the dashboard as soon as the request returns. Stored files are deleted in the background, normally within a minute, and a recently ended call's storage is checked again an hour later for a recording that finished uploading after the delete. Backups age out on a roughly 35-day cycle, and a deleted call is deleted again if a backup is ever restored. Deletion cannot be undone and sends no webhook event.
| Status | Condition |
|---|---|
204 | Call deleted |
403 | The authenticated user is not an owner or admin |
404 | No such call in your organization, including a call that was already deleted |
409 | code: "call_in_progress": the call is live, or its billing is still being settled right after it ended. Retry in a few minutes. code: "call_legal_hold": the call is under a legal hold |
Delete calls in bulk
POST /v1/calls/bulk-delete deletes up to 100 calls in one request, with
the same rules and effects as deleting one call. The
response has one result per distinct call ID.
curl -X POST https://api.thunderphone.com/v1/calls/bulk-delete \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"call_ids": [987654321, 987654322, 987654323]}'{
"results": [
{"call_id": 987654321, "status": "deleted", "detail": ""},
{"call_id": 987654322, "status": "not_found", "detail": ""},
{"call_id": 987654323, "status": "in_progress", "detail": "This call is still in progress. Wait for it to end, then delete it."}
],
"deleted": 1
}status | Meaning |
|---|---|
deleted | Deleted by this request |
not_found | No such call in your organization, or it was already deleted |
in_progress | Live, or its billing is still being settled; nothing was deleted. Retry later |
legal_hold | Under a legal hold; nothing was deleted |
The request returns 400 for an empty list, more than 100 IDs, or IDs that
are not positive integers, and 403 when the authenticated user is not an
owner or admin.
Preview a deletion
POST /v1/calls/bulk-delete/preview takes the same body as
bulk delete and deletes nothing. It returns how
many validation examples were made from those calls, which deleting them
would also remove. IDs that are not calls in your organization count
nothing. Owners and admins only, like deletion.
curl -X POST https://api.thunderphone.com/v1/calls/bulk-delete/preview \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"call_ids": [987654321, 987654322]}'{
"validation_examples": 2
}The request returns 400 and 403 in the same cases as bulk delete.
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 |
Run data extraction
Re-run structured data extraction for a completed call using the agent's
current deployed extraction schema. This admin-only endpoint overwrites the
call's prior extraction row and emits call.data_extracted when extraction
completes.
curl -X POST https://api.thunderphone.com/v1/calls/987654321/extract \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"{
"id": 412,
"call_id": 987654321,
"agent_id": 12,
"schema_snapshot": [
{
"key": "customer_name",
"type": "string",
"description": "The caller's full name",
"required": true,
"ask": true
}
],
"schema_version": "92850758e231a3c95a...",
"data": {"customer_name": "Alex Morgan"},
"evidence": {
"customer_name": {
"quote": "My name is Alex Morgan",
"speaker_role": "caller",
"turn_index": 4
}
},
"verification": "verified",
"field_reasons": {},
"status": "completed",
"error_message": "",
"model": "<model id>",
"extracted_at": "2026-04-20T18:25:11.002Z",
"created_at": "2026-04-20T18:25:11.002Z",
"updated_at": "2026-04-20T18:25:11.002Z"
}The response is still 200 OK when the extraction row has status="failed"
or status="skipped"; inspect error_message for the reason. Calls whose
immutable recording policy disabled retained content return 409 Conflict
with status="skipped_recording_disabled" and are never sent to the model.
A call outside the authenticated organization returns 404.
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 |