ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Calls

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

MethodPathDescription
GET/v1/callsList calls (paginated, filterable)
GET/v1/calls/exportBulk export up to 1,000 calls as CSV or JSON
GET/v1/calls/party-numbersPhone numbers seen on your calls, for filter autocomplete
GET/v1/calls/report-summaryCall-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-deletePermanently delete up to 100 calls (admin)
POST/v1/calls/bulk-delete/previewCount what deleting up to 100 calls would also remove (admin)
GET/v1/calls/{call_id}/audioGet 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}/transcriptFetch the transcript
GET/v1/calls/{call_id}/historyFetch the full structured turn history
GET/v1/calls/{call_id}/gradeFetch the latest AI quality grade
POST/v1/calls/{call_id}/gradeRun (or re-run) AI grading
POST/v1/calls/{call_id}/extractRe-run structured data extraction
GET/v1/calls/{call_id}/issue-reportsList issue reports for a call
POST/v1/calls/{call_id}/issue-reportsFile an issue report
POST/v1/calls/{call_id}/listenMint a listen-only token for a live call
POST/v1/calls/{call_id}/whisperSend private text guidance to the agent on a live call
POST/v1/calls/{call_id}/promote-to-scenarioTurn 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"
  }
}
FieldTypeDescription
call_idintegerServer-assigned call id (also the key in every other endpoint on this page)
agent_idinteger | nullAgent that handled the call
agent_variant_idinteger | nullSplit-testing variant selected for this call; null when no split test ran
agent_variant_labelstring | nullLabel of that variant, snapshotted at call time (survives variant deletion)
directionstringinbound, outbound, test (test call in the agent builder), or web (web call from the embeddable widget)
from_numberstringE.164 caller number. Test calls (direction: "test") use an internal identifier
to_numberstringE.164 destination number
start_time, end_timetimestamp | nullISO 8601 UTC. end_time is null while status="in_progress"
duration_secondsinteger | nullDerived
statusstringin_progress, completed, or failed
end_reasonstring | nullWhy 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_statusinteger | nullFor 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_reasonstringThe 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_kindstring | nullWhat 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_urlstring | nullStorage URL when set — not guaranteed playable. Use /audio for a signed, playable URL
recording_started_attimestamp | nullWhen 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_attimestamp | nullWhen 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_reasonstring | nullWhy 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_simulationbooleanTrue if the call was produced by a simulation run
is_test_callbooleanDeprecated alias for is_simulation; retained during the transition
is_scenario_callbooleanTrue only for runs launched from a scenario batch — ad-hoc simulations stay false
prompt_snapshotstring | nullThe user-authored agent prompt in effect at call time
tool_snapshotarray | nullThe tool definitions the agent had at call time
cloud_run_execution_namestring | nullInternal identifier for the worker that ran the call. Useful only when corresponding with support; may be null
livekit_room_name, livekit_room_sidstring | nullInternal identifiers for the underlying media room. Do not build on these; they exist for supervision/support tooling
product, voicestring | nullThe Agent config in effect at call time
billable_minutesnumber | nullMinutes 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_centsinteger | nullFinal bill in USD cents
billed_attimestamp | nullWhen billing finalised
transfer_outcomestring | nullHow 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_kindstring | nullFor 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_destinationstringThe number the call was transferred to; "" for a call without a transfer
transfer_sip_statusinteger | nullThe carrier's SIP status when it refused a cold transfer (403, 405 or 501); null when it did not
transfer_bridge_secondsinteger | nullBridged time billed, in seconds; null until the bridge ends, and for calls without one
transfer_bridge_total_centsinteger | nullWhat 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_idinteger | nullThe 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_scoreinteger | null0–100 quality score from the latest AI grading
grade_statusstring | nullpending, completed, or failed
grade_updated_attimestamp | nullLast grading run
call_outcomestring | nullFrom the latest grade: success, failure, unknown, or no_conversation
extracted_dataobject | nullStructured 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
summarystring | nullPlain-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_statusstring | nullcompleted; 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_countintegerTotal issue reports filed against this call
critical_issue_countintegerSubset with severity critical
reportobject | nullThe 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_tracearray | nullThe 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_valuesobject | nullThe 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_evidenceobjectConsent-announcement evidence record — see Consent evidence. {} when no announcement played

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:

FieldTypeDescription
asset_version, asset_hashstringIdentity of the exact audio asset that played; asset_hash keys the consent announcement audio endpoint
language, announcement_textstringAnnouncement language and verbatim text
playback_started_at, playback_completed_at, capture_started_attimestampOrdered: playback completes before caller-audio capture is enabled
continuation_eventobject | absentFirst 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_eventsarray | absentDetectable 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
curl 'https://api.thunderphone.com/v1/calls?limit=50' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Python
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"]:
    ...
Node.js
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

ParamTypeDefaultDescription
limitinteger50Page size (1–200)
offsetinteger0
agent_idintegerFilter by agent
hoursstringbusiness 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_attentionboolean1 keeps only calls whose report flags a follow-up
outcome_keystringComma-separated report outcome keys (for example appointment_set,callback_requested)
exclude_test_numbersboolean1 drops calls from or to the organization's excluded test numbers
attention_firstboolean1 sorts flagged calls to the top (newest first within each group) instead of pure recency
directionstringinbound, 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.
statusstringin_progress, completed, failed
phone_numberstringMatches either side of the call (from_number or to_number); formatted numbers are normalized before matching
from_numberstringE.164
to_numberstringE.164
start_dateISO 8601Earliest start_time
end_dateISO 8601Latest start_time
exclude_test_callsbooleanfalsetrue 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.

FieldTypeDescription
statusstringcompleted once written; pending while batch grading is queued (fields are placeholders); skipped when recording was disabled for the call (no findings)
outcome_keystringThe configured key the grader picked; "" when no configured outcome applies
outcome_labelstringThe picked label. Quick hang-ups show No conversation; "" when no label is available
outcome_colorstringThe 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_reasoningstringOne sentence on why that label fits
needs_attentionbooleanSomeone at the business should follow up or review the call
attention_reasonstringurgent, caller_frustrated, asked_for_person, agent_did_not_know, detail_capture_trouble, booking_failed, transfer_failed, or ""
attention_notestringOne sentence on what the follow-up is about
attention_turn_index, attention_time_secondsinteger | nullThe transcript turn and recording second where the reason became apparent
caller_namestringAs the caller gave it; "" when never stated
caller_is_newboolean | nullNew (true), existing (false), or unknown
call_reasonstringWhy the caller called. The grader aims for at most eight words; the value is capped at 200 characters
one_line_summarystringA 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_frustratedbooleanWhether the caller showed frustration
frustration_causestringWhen 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_notestringHow the caller seemed to feel about the call, including whether they were fine talking to an AI
hangup_stagestringAn 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_finishboolean | nullFully handled on the call with nothing left for the business to do
appointment_set, appointment_new_clientboolean, boolean | nullAn appointment, booking or reservation was confirmed; whether it was for a new client
transfer_result, booking_resultstringOn a completed report: not_attempted, succeeded, or failed. Pending and skipped reports use ""
detail_capture_troublebooleanThe agent struggled to get an email, name, number or other detail right
generated_attimestamp

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.

ParamTypeDefaultDescription
start_date, end_dateYYYY-MM-DD or ISO 8601last 30 daysBare 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_idintegerFilter by agent
directionstringinbound, outbound, test, or web. Historical mic and widget rows can be queried by those literal legacy values
hoursstringbusiness 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.

CSV
curl 'https://api.thunderphone.com/v1/calls/export?export_format=csv' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -o calls.csv
JSON (filtered)
curl '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.json

Query parameters

ParamRequiredDescription
export_formatyescsv or json
agent_idFilter by agent
directionSame values as List calls (inbound, outbound, test, web; legacy mic/widget accepted)
statusin_progress, completed, failed
phone_numberMatches either side of the call (from_number or to_number); formatted numbers are normalized before matching
from_numberE.164
to_numberE.164
start_dateISO 8601 — earliest start_time
end_dateISO 8601 — latest start_time
exclude_test_callstrue 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:

SignalWhereMeaning
X-Truncatedheader (CSV and JSON)true when the filtered match count exceeds the row cap, else false
X-Total-Countheader (CSV and JSON)Untruncated count of calls matching the filters
truncatedJSON body fieldSame 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

ParamRequiredDescription
qDigit search. Punctuation is ignored on both sides, so (631) 456 matches +16314566068
limitMaximum numbers to return (default 50, max 200)

Response

{
  "numbers": ["+16314566068", "+15137061124"]
}

Retrieve a call

cURL
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
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.

StatusCondition
204Call deleted
403The authenticated user is not an owner or admin
404No such call in your organization, including a call that was already deleted
409code: "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
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]}'
Response
{
  "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
}
statusMeaning
deletedDeleted by this request
not_foundNo such call in your organization, or it was already deleted
in_progressLive, or its billing is still being settled; nothing was deleted. Retry later
legal_holdUnder 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
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]}'
Response
{
  "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
curl https://api.thunderphone.com/v1/calls/987654321/audio \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{ "url": "https://storage.googleapis.com/thunderphone-recordings/..." }
StatusCondition
200Recording exists, URL returned
404Call 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.

StatusCondition
200Audio stream returned
404Call, turn, or audio not found

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
curl https://api.thunderphone.com/v1/consent-announcements/f1d6b49f4ac8f391cfc933591f53415bc71083a62d723acb4de5606cea3d4d73 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  --output consent-announcement.wav
StatusCondition
200WAV audio stream returned
404Hash malformed, not linked to any of your org's calls, or the asset is no longer cached
429Rate 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
curl https://api.thunderphone.com/v1/calls/987654321/transcript \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
  "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
    }
  ]
}
FieldTypeDescription
rolestringuser, agent, tool_call, tool_response, or system
contentstringHuman-readable content. For tool_call/tool_response turns this is a JSON string
start_ms, end_msintegerOffset in milliseconds, anchored to the recording when recording_started_at is set. Omitted when timing could not be reconstructed
ttfa_msintegerTime-to-first-audio for the first agent turn of a response. Present on agent turns only
caller_audio_refsarray of stringOpaque 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 paramDescription
live=trueUse 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
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
curl https://api.thunderphone.com/v1/calls/987654321/grade \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
  "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"
}
FieldTypeDescription
idintegerGrade row id
call_idinteger
statusstringpending, completed, or failed
scoreinteger | null0–100
call_outcomestringsuccess, failure, unknown, or no_conversation
outcome_reasoningstringWhy the grader chose that outcome
summarystringOne-paragraph call summary
rubric_breakdownobjectPer-criterion scores
detected_issuesarrayMachine-detected issues; completed grades sync these into issue reports with source: "system"
grader_model, grader_versionstringWhich grader produced the row
error_messagestring | nullSet when status="failed"
graded_attimestamp | nullWhen grading completed
created_at, updated_attimestamp

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
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
}
StatusCondition
200An existing completed grade was reused (no force)
201A new grade completed
429The per-call cooldown is active; use Retry-After or retryAfterSeconds before retrying
502Grading 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
curl -X POST https://api.thunderphone.com/v1/calls/987654321/extract \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
  "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
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
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"
  }'
FieldTypeRequiredDescription
titlestringyes1–255 chars
descriptionstringnoFree-form. Defaults to ""
severitystringnoinfo, warning, or critical. Defaults to warning
sourcestringnouser (default) or system
timestamp_secondsinteger ≥ 0 | nullnoOffset into the call where the issue occurred
detected_issue_refstringnoUp to 128 chars — correlates the report with a grader-detected issue
metadataobjectnoArbitrary 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
curl -X POST https://api.thunderphone.com/v1/calls/987654321/listen \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
  "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.

StatusCondition
200Token minted
404Call not found
409Call 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
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."}'
FieldTypeRequiredDescription
textstringyes1–500 chars of operator guidance
Response (202 Accepted)
{ "status": "sent" }
StatusCondition
202Guidance delivered to the live session
404Call not found
409Call is not live (including the brief window after hang-up)
502Delivery to the call runtime failed