---
title: "Calls"
description: "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](/api-reference/outbound-calls), 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](/api-reference/test-scenarios#promote-a-call-to-a-scenario) |

## Call object

```json
{
  "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](/api-reference/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](/api-reference/blocked-callers) |
| `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](/guides/call-transfers) 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](/api-reference/test-calls) 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](/api-reference/test-scenarios) — 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](/api-reference/agents) 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](/guides/call-transfers) ended: `refer_accepted`, `bridged`, `bridge_unanswered` or `failed` (see [`call.transfer_completed`](/webhooks/events#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](/api-reference/voip-connections) 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](/api-reference/voip-connections#turn-on-call-transfers) 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`](/api-reference/agents#deployed-fields) 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](/webhooks/call-complete) 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](#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](#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](#get-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

<CodeGroup>
```bash cURL
curl 'https://api.thunderphone.com/v1/calls?limit=50' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

```python 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"]:
    ...
```

```javascript 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());
```
</CodeGroup>

### 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](/guides/organization-settings#reporting), 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](/api-reference/test-calls) runs, and builder Talk sessions (`direction=test`). Web widget calls are kept. Values other than `true` or `false` (case-insensitive) return `400`. |

### Paginated response

```json
{
  "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`](/api-reference/agents#deployed-fields) (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`](/webhooks/events#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` |

```json
{
  "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`](/api-reference/agents#deployed-fields)
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](#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.

<CodeGroup>
```bash CSV
curl 'https://api.thunderphone.com/v1/calls/export?export_format=csv' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -o calls.csv
```

```bash 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
```
</CodeGroup>

### Query 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](#list-calls) —
it includes external callers, not only numbers you own. Placeholder
parties for browser, widget and test sessions are omitted.

```bash
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

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

---

## Retrieve a call

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/calls/987654321 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns `200 OK` with a [Call object](#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`.

<CodeGroup>
```bash cURL
curl -X DELETE https://api.thunderphone.com/v1/calls/987654321 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

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](/api-reference/validation-sets) 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](#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](#delete-a-call). The
response has one result per distinct call ID.

<CodeGroup>
```bash 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]}'
```
</CodeGroup>

```json 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
}
```

| `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](#delete-calls-in-bulk) 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.

<CodeGroup>
```bash 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]}'
```
</CodeGroup>

```json 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).

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/calls/987654321/audio \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json Response
{ "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.

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/consent-announcements/f1d6b49f4ac8f391cfc933591f53415bc71083a62d723acb4de5606cea3d4d73 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  --output consent-announcement.wav
```
</CodeGroup>

| 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`](#get-history).

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/calls/987654321/transcript \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json 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
    }
  ]
}
```

| 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`](#get-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 |

<Note>
The audio is uploaded before the turn appears in the transcript, but the two
are not transactional. A reference can briefly 404 immediately after it first
appears; retry for a few seconds. Measured worst case on a live call was under
a second. A reference that never becomes available is dropped from later reads
rather than left pointing at a recording that does not exist.
</Note>

---

## Get history

Full structured turn history. Use this when you need raw function-call
arguments, interruption markers, ack-prompt annotations, or first-byte
latencies.

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/calls/987654321/history \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns an array of typed entries. See the ThunderPhone [call-history
schema reference](/api-reference/calls#get-history)
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

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/calls/987654321/grade \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json 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"
}
```

| 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](#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.

<CodeGroup>
```bash cURL
curl -X POST 'https://api.thunderphone.com/v1/calls/987654321/grade?force=true' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

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:

```json
{
  "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.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/calls/987654321/extract \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json 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](/api-reference/issue-reports).

### List reports for a call

<CodeGroup>
```bash cURL
curl https://api.thunderphone.com/v1/calls/987654321/issue-reports \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

Returns an array of Issue Report objects — see [Issue Reports](/api-reference/issue-reports#issue-report-object).

### File a report

<CodeGroup>
```bash 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"
  }'
```
</CodeGroup>

| 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](/api-reference/issue-reports#issue-report-object).
To update a report's status later, use the
[issue-reports endpoints](/api-reference/issue-reports#update-a-reports-status)
(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.

<CodeGroup>
```bash cURL
curl -X POST https://api.thunderphone.com/v1/calls/987654321/listen \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
</CodeGroup>

```json 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.

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

<CodeGroup>
```bash 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."}'
```
</CodeGroup>

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `text` | string | yes | 1–500 chars of operator guidance |

```json Response (202 Accepted)
{ "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 |

---

## Related

<CardGroup cols={2}>
  <Card title="Outbound Calls" icon="arrow-up-right" href="/api-reference/outbound-calls">
    Trigger a new outbound call via `POST /v1/call`.
  </Card>
  <Card title="Mic Sessions" icon="microphone" href="/api-reference/mic-sessions">
    Test an agent live from a browser.
  </Card>
  <Card title="Issue Reports" icon="triangle-exclamation" href="/api-reference/issue-reports">
    Org-wide feed of issue reports across calls.
  </Card>
  <Card title="Simulations" icon="flask" href="/api-reference/test-calls">
    Programmatic batched simulations against an agent.
  </Card>
</CardGroup>
