Call Reports API
📈 Call Reports API
Section titled “📈 Call Reports API”Everything the Call Center Report shows is available as JSON: headline metrics, the quality scorecard, weekly trend, contact reasons, agent performance and shift comparison, plus a per-call table you can page through and drill into.
Typical uses are pulling compliance numbers into your own BI tool, mirroring per-call scores into a workforce management system, and polling for progress while a batch of uploads is being analyzed.
All endpoints accept either a session cookie or an API key — see Authentication. Results are scoped to the calling user exactly as in the web app: your own projects, projects shared with you, and every project in teams you administer.
Shared query parameters
Section titled “Shared query parameters”| Parameter | Type | Description |
|---|---|---|
project_id | int | Limit to a single project. Omit to aggregate across every call project you can see |
team | int | Team id, for team-scoped access. Ignored if you are not a member of that team |
start_date | date | Start of the window, YYYY-MM-DD (inclusive) |
end_date | date | End of the window, YYYY-MM-DD (inclusive, through 23:59:59) |
date_range | string | Named preset instead of explicit dates: mtd, last_month, last_30_days, last_90_days, ytd |
project_id and team apply to every endpoint on this page. The date parameters apply to the report, the PDF export and the calls list; the detail, analysis-status, custom-questions and recluster endpoints are project-scoped and ignore them.
date_range wins when both it and explicit dates are sent. With no date parameters at all, the whole history is returned — unlike the web report, which defaults to the last 30 days.
GET /calls/api/report/
Section titled “GET /calls/api/report/”The aggregated report for the selected projects and window.
Response (200 OK) — abridged, with long arrays cut to two entries:
{ "date_range": { "start": "2026-05-01T00:00:00+00:00", "end": "2026-07-30T00:00:00+00:00", "preset": "last_90_days" }, "total_calls_all_time": 57, "headline": { "total_calls": 57, "analyzed_hours": 7.3, "average_score": 74.2, "weighted_compliance": 78.4, "scored_calls": 57, "critical_error_pct": 7.0, "negative_sentiment_pct": 32.5, "goal_reached_pct": 75.0, "calls_with_metadata": 57 }, "scorecard": [ { "criterion_id": "verification", "name": "Identity verification", "section": "Opening", "weight_pct": 15.4, "average_pct": 75.4, "previous_pct": 71.0, "delta_pct": 4.4, "critical_errors": 0, "calls": 40, "evaluated_calls": 31, "not_applicable_calls": 9 } ], "trend": [ { "week": "2026-06-29", "calls": 10, "average_score": 65.0, "negative_sentiment_pct": 30.0 } ], "contact_reasons": [ { "topic": "Billing dispute", "calls": 8, "volume_pct": 20.0 } ], "agents": [ { "agent": "Ana Diaz", "agent_id": 1, "calls": 26, "average_score": 72.7, "critical_error_pct": 7.7, "negative_sentiment_pct": 50.0, "goal_reached_pct": 76.9 } ], "shifts": [ { "shift": "day", "total_calls": 46, "average_score": 74.8, "calls_with_metadata": 46 } ], "shift_hours": { "day_start": 8, "day_end": 20 }}Reading the sections:
| Field | Meaning |
|---|---|
total_calls_all_time | Calls in scope before the date filter. When headline.total_calls is 0 and this is not, your window is simply wrong |
headline | Volume and quality summary for the window. average_score is the plain mean of call scores; weighted_compliance is total points over total possible points, so long rubrics don’t get diluted |
scorecard | One row per rubric criterion. previous_pct and delta_pct compare against the immediately preceding window of the same length, and are null unless both start_date and end_date are set. not_applicable_calls counts the calls where the criterion was skipped and awarded full points; those calls are included in average_pct, so a criterion with a high not_applicable_calls reads high because it rarely applied. evaluated_calls + not_applicable_calls is less than calls when the window includes calls analyzed before Uspeech recorded applicability |
trend | Weekly buckets, week being the Monday of each bucket |
contact_reasons | Top reasons by volume. A trailing row with an empty topic and an other_topics count is the “everything else” bucket |
agents | Per-agent rollup. agent_id is Uspeech’s internal agent id — the one to pass to the explorer’s agent filter — not your own agent_id from the metadata |
shifts | Same shape as headline plus shift (day / night). Only calls with a real call date/time are classified |
shift_hours | The day-shift window used, from the project’s settings |
Percentage fields are null rather than 0 when nothing in the window could be measured — no scored calls, no sentiment, no known outcome.
Example:
curl -H "Authorization: Api-Key $USPEECH_KEY" \ "https://app.uspeech.io/calls/api/report/?project_id=312&date_range=last_30_days"import osimport requests
BASE_URL = "https://app.uspeech.io"HEADERS = {"Authorization": f"Api-Key {os.environ['USPEECH_KEY']}"}
report = requests.get( f"{BASE_URL}/calls/api/report/", headers=HEADERS, params={"project_id": 312, "date_range": "last_30_days"}, timeout=60,).json()print(report["headline"])const BASE_URL = 'https://app.uspeech.io';const HEADERS = { Authorization: `Api-Key ${process.env.USPEECH_KEY}` };
const params = new URLSearchParams({ project_id: '312', date_range: 'last_30_days' });const report = await fetch(`${BASE_URL}/calls/api/report/?${params}`, { headers: HEADERS,}).then((r) => r.json());console.log(report.headline);GET /calls/api/calls/
Section titled “GET /calls/api/calls/”A paginated, filterable table of analyzed calls, spanning every analysis batch in the selected projects.
Additional query parameters:
| Parameter | Type | Description |
|---|---|---|
agent | int / none | Uspeech agent id (from the report’s agents[].agent_id), or none for calls with no agent |
sentiment | string | positive, neutral or negative |
direction | string | inbound or outbound |
critical | bool | true returns only calls with a critical error |
search | string | Matches file name, topic, contact reason, caller number or campaign |
ordering | string | call_datetime, score_percentage, mistake_count or agent__name, optionally prefixed with -. Defaults to -call_datetime |
page | int | Page number; the page size is 25 |
Unknown values for sentiment, direction and ordering are ignored rather than rejected.
Response (200 OK):
{ "count": 57, "next": "https://app.uspeech.io/calls/api/calls/?page=2", "previous": null, "results": [ { "id": 28, "file_id": 458, "file_name": "call_027.mp3", "call_datetime": "2026-07-28T09:15:00Z", "agent": "Luis Gomez", "direction": "inbound", "campaign": "Retention", "topic": "Address change", "sentiment": "positive", "score_percentage": 66.3, "has_critical_error": false, "mistake_count": 0, "missing_prompt_count": 1, "goal_reached": true } ]}id is the call’s id for the detail endpoint below; file_id is the uploaded file, which is what the Transcription and Call Metadata endpoints use. topic is the codified contact reason once grouping has run, and the raw extracted topic before that.
Example — the worst-scoring inbound calls of the month:
curl -H "Authorization: Api-Key $USPEECH_KEY" \ "https://app.uspeech.io/calls/api/calls/?date_range=mtd&direction=inbound&ordering=score_percentage"calls = requests.get( f"{BASE_URL}/calls/api/calls/", headers=HEADERS, params={ "date_range": "mtd", "direction": "inbound", "ordering": "score_percentage", }, timeout=60,).json()for call in calls["results"]: print(call["file"], call["score_percentage"])const params = new URLSearchParams({ date_range: 'mtd', direction: 'inbound', ordering: 'score_percentage',});const calls = await fetch(`${BASE_URL}/calls/api/calls/?${params}`, { headers: HEADERS,}).then((r) => r.json());for (const call of calls.results) console.log(call.file, call.score_percentage);GET /calls/api/calls/{id}/
Section titled “GET /calls/api/calls/{id}/”Everything known about one call, merged across analysis types.
Response (200 OK):
{ "record": { "id": 28, "file_id": 458, "file_name": "call_027.mp3", "call_datetime": "2026-07-28T09:15:00Z", "call_datetime_source": "manifest", "agent": "Luis Gomez", "direction": "inbound", "campaign": "Retention", "caller_number": "+34600111222", "topic": "Address change", "sentiment": "positive", "score_percentage": 66.3, "total_points": 19.89, "max_possible_points": 30.0, "has_critical_error": false, "mistake_count": 0, "missing_prompt_count": 1, "goal_reached": true }, "copc": { "…": "per-criterion scores, justifications and quotes" }, "no_protocol": { "…": "detected mistakes and missed prompts" }, "custom_questions": { "…": "answers to the project's custom questions" }}record is the explorer row plus the caller number, the raw point totals and call_datetime_source. The other three keys carry the free-text output of each analysis type and are null when that analysis has not run for this call. Their internal structure follows the analysis result format and is best inspected against a real call.
Each entry in copc.criterion_scores carries an applicability field — "evaluated" or "not_applicable":
{ "criterion_id": "closing_summary", "justification": "The customer hung up before the agent could summarize the agreement.", "quote": "[04:12] Customer: I have to go — goodbye.", "applicability": "not_applicable", "score": 15, "max_points": 15, "is_critical_error": false}A criterion that did not apply is awarded its full max_points, so it is indistinguishable from a perfect score on score alone — applicability is the only field that separates them. If you compute your own per-criterion averages, filter out not_applicable rows first; otherwise a conditional criterion reads high simply because it rarely applied. See How Scoring Works.
Errors: 404 when the call does not exist or belongs to a project you cannot see.
GET /calls/api/analysis-status/
Section titled “GET /calls/api/analysis-status/”Progress of automatic analysis for the selected projects — designed for polling while uploads are being processed.
Response (200 OK):
{ "analysis_status": { "calls_analysis_copc": { "completed": 54, "queued": 3 }, "calls_analysis_no_protocol": { "completed": 57 } }, "analyzed_calls": 57, "duplicate_uploads": 2, "in_progress": true, "reclustering": false, "last_analyzed_at": "2026-07-29T02:50:11.127124+00:00"}| Field | Meaning |
|---|---|
analysis_status | Per analysis type, a count of calls in each state (queued, processing, completed, failed) |
analyzed_calls | Calls with results, ignoring any date filter |
duplicate_uploads | Files uploaded under a name the project already had. They are the same call, so a record replaces its predecessor — which is why the file count can run ahead of the call count |
in_progress | true while any analysis is queued or processing — poll until it is false, then re-fetch the report |
reclustering | true while contact reasons are being re-grouped |
last_analyzed_at | Timestamp of the most recent result, or null |
while :; do resp=$(curl -sS "https://app.uspeech.io/calls/api/analysis-status/?project_id=312" \ -H "Authorization: Api-Key $USPEECH_KEY") [ "$(echo "$resp" | jq -r .in_progress)" = "false" ] && break sleep 30doneimport time
while True: status = requests.get( f"{BASE_URL}/calls/api/analysis-status/", headers=HEADERS, params={"project_id": 312}, timeout=30, ).json() if not status["in_progress"]: break time.sleep(30)const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
while (true) { const status = await fetch( `${BASE_URL}/calls/api/analysis-status/?project_id=312`, { headers: HEADERS }, ).then((r) => r.json()); if (!status.in_progress) break; await sleep(30_000);}See Uploading Calls for Analysis for the full upload-then-poll walkthrough.
GET /calls/api/custom-questions/
Section titled “GET /calls/api/custom-questions/”Per-call answers to the project’s custom questions, gathered across batches.
Response (200 OK):
{ "questions": ["Was a callback offered?", "Was the discount mentioned?"], "calls": [ { "call_analysis_id": 904, "file": "call_027.mp3", "call_datetime": "2026-07-28T09:15:00Z", "agent": "Luis Gomez", "answers": { "Was a callback offered?": { "answer": "Yes — agent offered a callback for Tuesday.", "quotes": 2 } } } ], "count": 12}questions is the union of questions seen across the returned calls, in first-seen order — use it as the column list. A call that was analyzed under an older question set simply has no entry for the newer questions. quotes counts the supporting transcript quotes behind the answer.
Projects without custom questions return empty arrays and count: 0.
POST /calls/api/recluster/
Section titled “POST /calls/api/recluster/”Re-group open-ended contact reasons on demand, the same action as Refresh contact reasons in the report.
Reclustering is queued for every accessible open-ended project; projects with frozen predefined codes classify each call on arrival and are skipped, so they never appear in the response. The work runs in the background — poll analysis-status for reclustering to see when it finishes.
Response (202 Accepted):
{ "reclustering": [312, 318], "count": 2 }reclustering lists the project ids that were queued. Overlapping requests are de-duplicated, so calling this while a recluster is already running is harmless.
Example:
curl -X POST -H "Authorization: Api-Key $USPEECH_KEY" \ "https://app.uspeech.io/calls/api/recluster/?project_id=312"response = requests.post( f"{BASE_URL}/calls/api/recluster/", headers=HEADERS, params={"project_id": 312}, timeout=30,)response.raise_for_status()const response = await fetch( `${BASE_URL}/calls/api/recluster/?project_id=312`, { method: 'POST', headers: HEADERS },);if (!response.ok) throw new Error(await response.text());GET /calls/api/report/pdf/
Section titled “GET /calls/api/report/pdf/”The same client-ready PDF the report’s Export PDF button produces, including the AI-written executive summary, for the current filters.
Response (200 OK): application/pdf as a file download. Generation runs synchronously and includes an LLM call for the narrative sections, so expect this to take noticeably longer than the JSON endpoint; if the narrative fails, the PDF is still returned with its tables and a note.
The document is written in English by default. Send Accept-Language: es for Spanish — a key-authenticated request has no browser session, so the language preference on the user’s profile does not apply here.
Example:
curl -H "Authorization: Api-Key $USPEECH_KEY" \ -o call_report.pdf \ "https://app.uspeech.io/calls/api/report/pdf/?project_id=312&date_range=last_month"response = requests.get( f"{BASE_URL}/calls/api/report/pdf/", headers=HEADERS, params={"project_id": 312, "date_range": "last_month"}, timeout=600,)response.raise_for_status()with open("call_report.pdf", "wb") as fh: fh.write(response.content)import { writeFile } from 'node:fs/promises';
const params = new URLSearchParams({ project_id: '312', date_range: 'last_month' });const response = await fetch(`${BASE_URL}/calls/api/report/pdf/?${params}`, { headers: HEADERS,});if (!response.ok) throw new Error(await response.text());await writeFile('call_report.pdf', Buffer.from(await response.arrayBuffer()));Errors
Section titled “Errors”| Status | When |
|---|---|
401 / 403 | Missing, malformed, revoked or inactive-user API key |
404 | On the report and PDF endpoints, project_id names a project that does not exist or is not visible to you; on the detail endpoint, the same for the call itself |
Note the asymmetry: an unreachable project_id is a 404 on the report and the PDF, but on the list, status, custom-questions and recluster endpoints it simply matches nothing and you get an empty, zeroed result. An empty result is never an error in itself — a project you can see with no analyzed calls looks the same. See Status Codes & Errors for the general error shape.