Uploading Calls for Analysis
📤 Uploading Calls for Analysis
Section titled “📤 Uploading Calls for Analysis”When a call project has automatic analysis enabled, every recording is analyzed as soon as it finishes transcribing — no separate “run analysis” call is needed.
The important thing to know: the API upload endpoint is the same one the web app uses.
Dropping a file into the Conversations panel and POSTing it to /api/files/ run through
exactly the same server-side path, so a recording uploaded by a script is transcribed, gets its
CallRecord, and is queued for automatic analysis identically to one uploaded by hand. There is
nothing API-specific to turn on.
All endpoints require an API key — see Authentication.
The flow at a glance
Section titled “The flow at a glance”- Configure the project once — enable automatic analysis and choose the analysis types.
POST /api/files/— upload each recording, optionally with its call metadata.- Poll
GET /calls/api/analysis-status/?project_id=…untilin_progressisfalse. - Read the results from the Call Reports API.
Steps 2–4 are all you repeat day to day.
Every step needs the project’s numeric ID. If you don’t have it, Projects shows how to list your projects and where to find the ID in the web app.
1. Configure automatic analysis
Section titled “1. Configure automatic analysis”Automatic analysis is configured on the project, via PATCH /api/projects/{id}/. You can do
this from the web app’s analysis wizard instead — the settings are the same either way.
Fields:
| Field | Type | Description |
|---|---|---|
auto_analysis_enabled | bool | Master switch. Analysis only happens when this is true. |
auto_analysis_types | list | Which analyses to run per call. One or more of calls_analysis_copc, calls_analysis_no_protocol, custom_questions. |
auto_analysis_questions | list of strings | The question set answered per call. Required when custom_questions is enabled. |
evaluation_rubric | int | ID of the rubric to score against. Required when calls_analysis_copc is enabled. |
auto_analysis_batch_size | int | Dispatch a batch once this many calls are waiting. Default 20, allowed range 1–20. |
auto_analysis_max_wait_seconds | int | Flush a partial batch once its oldest waiting call is this old. Default 300. |
Validation — the request is rejected with 400 when the configuration would produce
unusable runs:
- The project’s
audio_typeis notcall— automatic analysis is call-projects only. auto_analysis_typesis empty.calls_analysis_copcis requested without anevaluation_rubric.custom_questionsis requested without anyauto_analysis_questions.auto_analysis_batch_sizeis outside1–20.
Example — enable per-call analysis with no protocol plus two custom questions:
curl -X PATCH https://app.uspeech.io/api/projects/312/ \ -H "Authorization: Api-Key $USPEECH_KEY" \ -H "Content-Type: application/json" \ -d '{ "auto_analysis_enabled": true, "auto_analysis_types": ["calls_analysis_no_protocol", "custom_questions"], "auto_analysis_questions": ["Was a callback offered?", "Was the discount mentioned?"], "auto_analysis_batch_size": 20, "auto_analysis_max_wait_seconds": 300 }'import osimport requests
BASE_URL = "https://app.uspeech.io"HEADERS = {"Authorization": f"Api-Key {os.environ['USPEECH_KEY']}"}project_id = 312
response = requests.patch( f"{BASE_URL}/api/projects/{project_id}/", headers=HEADERS, json={ "auto_analysis_enabled": True, "auto_analysis_types": ["calls_analysis_no_protocol", "custom_questions"], "auto_analysis_questions": [ "Was a callback offered?", "Was the discount mentioned?", ], "auto_analysis_batch_size": 20, "auto_analysis_max_wait_seconds": 300, }, timeout=30,)response.raise_for_status()print(response.json()["auto_analysis_types"])const BASE_URL = 'https://app.uspeech.io';const HEADERS = { Authorization: `Api-Key ${process.env.USPEECH_KEY}` };const projectId = 312;
const response = await fetch(`${BASE_URL}/api/projects/${projectId}/`, { method: 'PATCH', headers: { ...HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ auto_analysis_enabled: true, auto_analysis_types: ['calls_analysis_no_protocol', 'custom_questions'], auto_analysis_questions: ['Was a callback offered?', 'Was the discount mentioned?'], auto_analysis_batch_size: 20, auto_analysis_max_wait_seconds: 300, }),});if (!response.ok) throw new Error(await response.text());console.log((await response.json()).auto_analysis_types);GET /api/projects/{id}/ returns the same fields, so you can check the current configuration
before changing it.
2. Upload the recordings
Section titled “2. Upload the recordings”POST /api/files/ — identical to the Transcription API upload, and it
accepts the call metadata in the same request.
Content-Type: multipart/form-data
| Field | Required | Description |
|---|---|---|
project | yes | The project ID. |
file | yes | The recording (mp3, wav, m4a, flac, ogg, mp4, …), or an srt/vtt transcript. |
file_type | yes | audio for recordings, or srt / vtt for transcripts you already have. |
call_datetime, agent_id, agent_name, direction, caller_number, campaign | no | Call metadata — see Call Metadata. |
Metadata is optional here, but supplying it is what makes the report groupable by agent, direction, campaign and date. For bulk loads you can skip it at upload time and send a CSV manifest instead — rows are matched to files by name, including files uploaded later.
curl -X POST https://app.uspeech.io/api/files/ \ -H "Authorization: Api-Key $USPEECH_KEY" \ -F "project=312" \ -F "file_type=audio" \ -F "file=@./call_001.mp3" \ -F "call_datetime=2026-06-15T09:12:00Z" \ -F "agent_id=A-06" \ -F "agent_name=Ana Diaz" \ -F "direction=inbound" \ -F "campaign=Retention"with open("call_001.mp3", "rb") as fh: response = requests.post( f"{BASE_URL}/api/files/", headers=HEADERS, data={ "project": project_id, "file_type": "audio", "call_datetime": "2026-06-15T09:12:00Z", "agent_id": "A-06", "agent_name": "Ana Diaz", "direction": "inbound", "campaign": "Retention", }, files={"file": ("call_001.mp3", fh, "audio/mpeg")}, timeout=300, )response.raise_for_status()file_id = response.json()["id"]print("uploaded", file_id)import { openAsBlob } from 'node:fs';
const form = new FormData();form.set('project', String(projectId));form.set('file_type', 'audio');form.set('call_datetime', '2026-06-15T09:12:00Z');form.set('agent_id', 'A-06');form.set('agent_name', 'Ana Diaz');form.set('direction', 'inbound');form.set('campaign', 'Retention');form.set('file', await openAsBlob('./call_001.mp3'), 'call_001.mp3');
const response = await fetch(`${BASE_URL}/api/files/`, { method: 'POST', headers: HEADERS, // do not set Content-Type — FormData sets the boundary body: form,});if (!response.ok) throw new Error(await response.text());const fileId = (await response.json()).id;console.log('uploaded', fileId);⚠️ Important: 201 Created means the upload was accepted and queued. Transcription and analysis
both happen asynchronously — see the polling section below.
Re-uploading a recording
Section titled “Re-uploading a recording”A file uploaded under a name the project already has is treated as the same call — the new record replaces its predecessor rather than adding a second one. This makes retries safe: if a batch upload fails halfway, re-running it will not double-count calls in the report.
3. How batching works
Section titled “3. How batching works”Calls are not analyzed one at a time — they are analyzed in small batches, which is why results appear in groups rather than one by one.
A batch for a given analysis type is dispatched when either condition is met:
auto_analysis_batch_sizecalls are waiting — the batch goes immediately.- The oldest waiting call reaches
auto_analysis_max_wait_seconds— the partial batch is flushed by a sweep that runs every 60 seconds.
So a partial batch is never stranded: with the defaults, a lone call uploaded at the end of the
day is analyzed within about five minutes. If you upload in large runs, lower
auto_analysis_max_wait_seconds only if you need results sooner than that — smaller batches
cost more per call.
4. Track progress
Section titled “4. Track progress”Two levels of visibility:
Per file — GET /api/files/{id}/ reports the transcription status (transcribing →
transcribed | failed) and an auto_analysis_error field that surfaces the reason automatic
analysis was skipped for that file, e.g. quota_exceeded when the subscription ran out of
minutes.
Per project — GET /calls/api/analysis-status/?project_id={id} is the endpoint the
dashboard itself polls:
{ "analysis_status": { "calls_analysis_no_protocol": { "completed": 57, "queued": 3 } }, "analyzed_calls": 57, "duplicate_uploads": 2, "in_progress": true, "reclustering": false, "last_analyzed_at": "2026-07-29T02:50:11.127124+00:00"}Poll it until in_progress is false, then fetch the report. See
Call Reports for the full field reference.
while :; do resp=$(curl -sS "https://app.uspeech.io/calls/api/analysis-status/?project_id=312" \ -H "Authorization: Api-Key $USPEECH_KEY") in_progress=$(echo "$resp" | jq -r .in_progress) echo "$(date -u +%H:%M:%S) analyzed=$(echo "$resp" | jq -r .analyzed_calls) in_progress=$in_progress" [ "$in_progress" = "false" ] && break sleep 30doneimport time
while True: status = requests.get( f"{BASE_URL}/calls/api/analysis-status/", headers=HEADERS, params={"project_id": project_id}, timeout=30, ).json() print(f"analyzed={status['analyzed_calls']} in_progress={status['in_progress']}") 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=${projectId}`, { headers: HEADERS }, ).then((r) => r.json()); console.log(`analyzed=${status.analyzed_calls} in_progress=${status.in_progress}`); if (!status.in_progress) break; await sleep(30_000);}💡 Tip: poll every 30 seconds or slower. Transcription takes roughly 1/8 of the audio duration, and analysis waits for a batch to fill, so faster polling tells you nothing new.
5. Read the results
Section titled “5. Read the results”Once in_progress is false, the analyzed calls are available through the
Call Reports API:
GET /calls/api/report/?project_id={id}— the aggregated QA reportGET /calls/api/calls/?project_id={id}— the per-call explorerGET /calls/api/custom-questions/?project_id={id}— per-call answers to your custom questionsGET /calls/api/report/pdf/?project_id={id}— the PDF export
End-to-end example
Section titled “End-to-end example”Configure the project, upload a folder of recordings, attach their call metadata, wait for analysis, and fetch the report.
The metadata step matters: without it the calls are still analyzed, but the report has nothing to group them by — no agent, no direction, no campaign, and the upload time standing in for the call time. There are two ways to supply it, and you can mix them freely:
- Per file at upload, as extra form fields on
POST /api/files/— best when your system already knows the details of each call as it uploads it. This is what step 2 above shows. - Per project, as a CSV manifest on
POST /api/projects/{id}/call-manifest/— best for bulk loads and backfills. Rows are matched to files by name, and rows that don’t match anything yet are kept and applied to files uploaded later, so the manifest can be sent before or after the recordings.
The example below uses the manifest, since it suits uploading a whole folder at once. See Call Metadata for the full field reference, the accepted date formats, and how to correct metadata after the fact.
#!/usr/bin/env bashset -euo pipefail
BASE_URL="https://app.uspeech.io"AUTH="Authorization: Api-Key ${USPEECH_KEY}"project_id=312
# 1. Enable automatic analysiscurl -sS -X PATCH "${BASE_URL}/api/projects/${project_id}/" \ -H "$AUTH" -H "Content-Type: application/json" \ -d '{"auto_analysis_enabled": true, "auto_analysis_types": ["calls_analysis_no_protocol"]}' \ > /dev/null
# 2. Upload every recording in ./calls/for path in ./calls/*.mp3; do curl -sS -X POST "${BASE_URL}/api/files/" \ -H "$AUTH" \ -F "project=${project_id}" \ -F "file_type=audio" \ -F "file=@${path}" \ | jq -r '"uploaded \(.original_filename) -> \(.id)"'done
# 3. Attach the call metadata for the whole batch.# calls_june.csv: filename,call_datetime,agent_id,agent_name,direction,campaigncurl -sS -X POST "${BASE_URL}/api/projects/${project_id}/call-manifest/" \ -H "$AUTH" -F "file=@./calls_june.csv" \ | jq -r '"\(.matched) matched, \(.unmatched | length) waiting for their file"'
# 4. Wait for analysis to settlewhile :; do resp=$(curl -sS "${BASE_URL}/calls/api/analysis-status/?project_id=${project_id}" -H "$AUTH") [ "$(echo "$resp" | jq -r .in_progress)" = "false" ] && break sleep 30done
# 5. Fetch the reportcurl -sS "${BASE_URL}/calls/api/report/?project_id=${project_id}" -H "$AUTH" > report.jsonecho "report written to report.json"import osimport timefrom pathlib import Path
import requests
BASE_URL = "https://app.uspeech.io"HEADERS = {"Authorization": f"Api-Key {os.environ['USPEECH_KEY']}"}project_id = 312
# 1. Enable automatic analysisrequests.patch( f"{BASE_URL}/api/projects/{project_id}/", headers=HEADERS, json={ "auto_analysis_enabled": True, "auto_analysis_types": ["calls_analysis_no_protocol"], }, timeout=30,).raise_for_status()
# 2. Upload every recording in ./calls/for path in sorted(Path("calls").glob("*.mp3")): with path.open("rb") as fh: response = requests.post( f"{BASE_URL}/api/files/", headers=HEADERS, data={"project": project_id, "file_type": "audio"}, files={"file": (path.name, fh, "audio/mpeg")}, timeout=300, ) response.raise_for_status() print(f"uploaded {path.name} -> {response.json()['id']}")
# 3. Attach the call metadata for the whole batch.# calls_june.csv: filename,call_datetime,agent_id,agent_name,direction,campaignwith open("calls_june.csv", "rb") as fh: manifest = requests.post( f"{BASE_URL}/api/projects/{project_id}/call-manifest/", headers=HEADERS, files={"file": ("calls_june.csv", fh, "text/csv")}, timeout=120, )manifest.raise_for_status()result = manifest.json()print(f"{result['matched']} matched, {len(result['unmatched'])} waiting for their file")
# 4. Wait for analysis to settlewhile True: status = requests.get( f"{BASE_URL}/calls/api/analysis-status/", headers=HEADERS, params={"project_id": project_id}, timeout=30, ).json() if not status["in_progress"]: break time.sleep(30)
# 5. Fetch the reportreport = requests.get( f"{BASE_URL}/calls/api/report/", headers=HEADERS, params={"project_id": project_id}, timeout=60,).json()print(f"{report['headline']['total_calls']} calls in the report")import { readdir } from 'node:fs/promises';import { openAsBlob } from 'node:fs';
const BASE_URL = 'https://app.uspeech.io';const HEADERS = { Authorization: `Api-Key ${process.env.USPEECH_KEY}` };const projectId = 312;const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 1. Enable automatic analysisawait fetch(`${BASE_URL}/api/projects/${projectId}/`, { method: 'PATCH', headers: { ...HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ auto_analysis_enabled: true, auto_analysis_types: ['calls_analysis_no_protocol'], }),});
// 2. Upload every recording in ./calls/const names = (await readdir('calls')).filter((name) => name.endsWith('.mp3'));for (const name of names) { const form = new FormData(); form.set('project', String(projectId)); form.set('file_type', 'audio'); form.set('file', await openAsBlob(`calls/${name}`), name);
const response = await fetch(`${BASE_URL}/api/files/`, { method: 'POST', headers: HEADERS, body: form, }); if (!response.ok) throw new Error(`${name}: ${await response.text()}`); console.log(`uploaded ${name} -> ${(await response.json()).id}`);}
// 3. Attach the call metadata for the whole batch.// calls_june.csv: filename,call_datetime,agent_id,agent_name,direction,campaignconst manifestForm = new FormData();manifestForm.set('file', await openAsBlob('./calls_june.csv'), 'calls_june.csv');
const manifest = await fetch(`${BASE_URL}/api/projects/${projectId}/call-manifest/`, { method: 'POST', headers: HEADERS, body: manifestForm,});if (!manifest.ok) throw new Error(await manifest.text());const { matched, unmatched } = await manifest.json();console.log(`${matched} matched, ${unmatched.length} waiting for their file`);
// 4. Wait for analysis to settlewhile (true) { const status = await fetch( `${BASE_URL}/calls/api/analysis-status/?project_id=${projectId}`, { headers: HEADERS }, ).then((r) => r.json()); if (!status.in_progress) break; await sleep(30_000);}
// 5. Fetch the reportconst report = await fetch( `${BASE_URL}/calls/api/report/?project_id=${projectId}`, { headers: HEADERS },).then((r) => r.json());console.log(`${report.headline.total_calls} calls in the report`);Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
File reaches transcribed but nothing is ever analyzed | auto_analysis_enabled is false, or auto_analysis_types is empty. Check GET /api/projects/{id}/. |
auto_analysis_error is quota_exceeded | The subscription ran out of minutes. Analysis is skipped, not retried automatically. |
400 on the upload with a metadata field in the body | The project is not a call project, or file_type is not audio/srt/vtt. |
400 when enabling automatic analysis | A rubric is missing for COPC, questions are missing for custom_questions, or the batch size is outside 1–20. |
| Report call count is lower than the number of files uploaded | Re-uploaded filenames replace the earlier call. duplicate_uploads in the analysis-status response counts them. |
See Status Codes & Errors for the full list of response codes.