Reading calls
List an agency's or a rep's scored calls — practice or real, one source per request — and read one call's transcript, score and recording.
Two endpoints, both server-to-server with your API key: a slim list that answers "all calls for
this agency" and "all calls for this rep", and a full detail for one call's transcript, score and
recording. Practice calls (roleplay) and real calls (real_call) have the same shape, and you
read them one source at a time: source is required on the list.
List — GET /v1/calls
Returns scored calls of one source for your tenant, newest first (roleplay by endedAt,
real_call by startedAt), cursor-paginated.
Query parameters
| Param | Required | Notes |
|---|---|---|
from | ✅ | Lower bound (ISO 8601 UTC, inclusive) on when a roleplay call ended / a real_call started. |
source | ✅ | roleplay (practice calls) or real_call (an agency's real calls). One per request; omitting it is 400 INVALID_REQUEST. |
to | Upper bound (ISO 8601, inclusive), same axis as from. | |
agencyId | All calls for one agency, by its Athos id (athos_agency_…). Checked against your roster: an unknown or foreign id is 404 AGENCY_NOT_FOUND. | |
agentId | All calls for one rep, by their Athos id (athos_agent_…). Checked against your roster: an unknown or foreign id is 404 AGENT_NOT_FOUND. With an agencyId the rep is not in, the page is empty. | |
platformAgentId | One rep, by the platformAgentId you registered them under. An unknown value is an empty page. Not together with agentId. | |
platformAgencyId | One agency, by the platformAgencyId you registered it under. An unknown value is an empty page. Not together with agencyId. | |
limit | Page size, 1–100, default 50. Out of range or not an integer is 400 INVALID_REQUEST — it is not clamped. | |
cursor | Opaque token from a previous nextCursor. A cursor you did not get from this endpoint is 400 INVALID_REQUEST. |
Two filter pairs. agencyId / agentId are Athos ids and are checked: an agency or rep
that is not yours is a 404, never an empty page. platformAgencyId / platformAgentId are your
own ids and are plain filters: an unknown value is an empty page. Name each rep and each agency
one way per request — both names for one is 400 INVALID_REQUEST. Neither pair is required.
Response — a slim row per call (no score, no transcript):
{
"data": [
{
"id": "athos_call_GZ1DjOkZv_VEPoE-CJ8mp",
"source": "roleplay",
"agencyId": "athos_agency_V1StGXR8Z5jdHi6B-myT",
"agentId": "athos_agent_bR4kQ2wZ9Lp0nH3xVq1Yt",
"platformAgentId": "rep_8842",
"platformAgencyId": "acme-7731",
"drillKey": "ma-full-sale",
"startedAt": "2026-06-05T14:39:40.295Z",
"endedAt": "2026-06-05T14:49:52.295Z",
"durationSec": 612,
"scoredAt": "2026-06-05T14:50:29.005Z"
}
],
"nextCursor": "eyJ0IjoxNzQ5…"
}Every key is always present. platformAgentId / platformAgencyId are null when you never
registered one — join on agentId / agencyId then (see Core concepts).
drillKey is null on a real_call. Page with cursor and the same source, as described in
Paging.
Reconciling by polling
from and to filter on the call's own time, not on when it was scored — when it ended for
a roleplay call, when it started for a real_call — and results are ordered on that same axis.
Scoring finishes minutes after a call ends, so a call can become readable inside a window you have
already read past. When you reconcile (the backstop for a missed
webhook delivery), re-scan a window that
overlaps the last one by an hour plus the longest real call you expect, and deduplicate on
id. A forward-only watermark misses calls that were scored after you read past them.
# every practice call for one agency (source=real_call for its real calls)
curl -G https://app.useathos.ai/api/external/v1/calls \
-H "Authorization: Bearer $ATHOS_API_KEY" \
--data-urlencode "from=2026-06-01T00:00:00Z" \
--data-urlencode "source=roleplay" \
--data-urlencode "agencyId=athos_agency_V1StGXR8Z5jdHi6B-myT" \
--data-urlencode "limit=50"async function* callsFor(
from: string,
source: 'roleplay' | 'real_call',
scope: { agencyId?: string; agentId?: string },
) {
let cursor: string | null = null;
do {
const url = new URL('https://app.useathos.ai/api/external/v1/calls');
url.searchParams.set('from', from);
url.searchParams.set('source', source);
if (scope.agencyId) url.searchParams.set('agencyId', scope.agencyId);
if (scope.agentId) url.searchParams.set('agentId', scope.agentId);
if (cursor) url.searchParams.set('cursor', cursor);
const r = await fetch(url, { headers: { Authorization: `Bearer ${process.env.ATHOS_API_KEY}` } });
if (r.status === 404) throw new Error((await r.json()).error.code); // AGENCY_NOT_FOUND / AGENT_NOT_FOUND
const page = await r.json();
yield* page.data;
cursor = page.nextCursor;
} while (cursor);
}
// one rep's practice calls, newest first — a second loop with 'real_call' reads their real calls
for await (const call of callsFor('2026-06-01T00:00:00Z', 'roleplay', { agentId: rep.agentId })) {
console.log(call.id, call.drillKey, call.scoredAt);
}GET /v1/calls is a history read — scored calls only, never in-progress ones. The
call.scored webhook is your notification; use this endpoint to render history
and to reconcile.
Detail — GET /v1/calls/:callId
Look up one call of either source by its public id (from a list row, a webhook callId, or the
SDK ended event). An unknown id and another tenant's id both answer 404 CALL_NOT_FOUND.
curl https://app.useathos.ai/api/external/v1/calls/athos_call_GZ1DjOkZv_VEPoE-CJ8mp \
-H "Authorization: Bearer $ATHOS_API_KEY"Response (roleplay; complianceScorecard abridged):
{
"id": "athos_call_GZ1DjOkZv_VEPoE-CJ8mp",
"source": "roleplay",
"status": "scored",
"agencyId": "athos_agency_V1StGXR8Z5jdHi6B-myT",
"agentId": "athos_agent_bR4kQ2wZ9Lp0nH3xVq1Yt",
"platformAgentId": "rep_8842",
"platformAgencyId": "acme-7731",
"drillKey": "ma-full-sale",
"startedAt": "2026-06-05T14:39:40.295Z",
"endedAt": "2026-06-05T14:49:52.295Z",
"durationSec": 612,
"scoredAt": "2026-06-05T14:50:29.005Z",
"transcript": [
{ "role": "agent", "content": "Hi, this is Sam from the benefits line." },
{ "role": "beneficiary", "content": "Yeah, I saw something about the grocery card." }
],
"score": {
"overallScore": 82,
"summary": "Prospect asked about Medicare options; the agent built rapport but rushed the close.",
"feedback": "Lead with a clear greeting and one discovery question; slow down before presenting the plan.",
"compliancePercent": 85,
"complianceScorecard": {
"requiredCallOpening": {
"statedNameAndAgency": {
"yesNo": "yes",
"score": 5,
"explanation": "The agent gave their name and the agency in the first ten seconds."
},
"statedCallIsRecorded": {
"yesNo": "yes",
"score": 3,
"explanation": "The agent said the call was recorded before asking any questions."
}
},
"scopeOfAppointment": {
"confirmedProductsToDiscuss": {
"yesNo": "no",
"explanation": "The agent moved into the plan comparison without confirming the scope on file."
}
}
}
},
"audioUrl": "https://…/athos_call_GZ1DjOkZv_VEPoE-CJ8mp.ogg?X-Amz-Expires=3600&X-Amz-Signature=…",
"audioUrlExpiresAt": "2026-06-05T15:50:29.005Z",
"waveform": null,
"createdAt": "2026-06-05T14:39:45.000Z",
"updatedAt": "2026-06-05T14:50:29.100Z"
}The detail is a list row plus status (always "scored"), transcript, score, audioUrl,
audioUrlExpiresAt, waveform (reserved, always null), createdAt and updatedAt.
The transcript
A flat list of turns, in the order they were spoken. The same shape for both sources.
| Field | Type | What it is |
|---|---|---|
role | "agent" or "beneficiary" | Who spoke. agent is your rep — the human being graded. beneficiary is the customer — the AI persona on a practice call, the real person on the line on a real_call. |
content | string | What was said on that turn. |
startTime, endTime | number (optional) | Turn boundaries in seconds from the start of the call. Present on real_call turns only. |
The score object
| Field | Type | What it is |
|---|---|---|
overallScore | number | Overall call performance, 0–100 (higher is better); on a real_call the scale runs 60–100. 0 means no headline score could be produced. |
summary | string | A short natural-language summary of the call. |
feedback | string | Coaching feedback for the rep. |
compliancePercent | number (optional) | How compliant the call was — 0–100 (higher is better), comparable across calls whichever scorecard graded them. Present whenever the call was assessed for compliance. |
complianceScorecard | object (optional) | The default Medicare rubric's compliance review, section by section — the graded items and their per-item detail. No total; read compliancePercent for the number. |
summary and feedback are always present but either can be an empty string — on a very short
call, or when the analysis step did not complete, the call still scores but has no prose.
compliancePercent is the compliance number. It answers how compliant was this call —
0–100, comparable across calls whichever scorecard graded them, and the one to render and to
compare. complianceScorecard is the default Medicare rubric's section-by-section detail —
the graded items, not a total. A call can carry compliancePercent on its own.
complianceScorecard is optional — check for it, don't assume it. On practice calls it appears
on the two Medicare Advantage enrollment drills only — ma-full-sale and ma-t65 — and only
when the call ran at least five minutes; a shorter enrollment call and every other drill (the
other MA drills included) omit the key. On a real call it appears only on a Medicare Advantage
call that ended in a sale — never on a Final Expense one. And an agency graded by its own
compliance scorecard rather than the default rubric carries compliancePercent with no
complianceScorecard, because this blob is the default rubric's detail and that rubric did not
run. When there is none the key is absent, not null. Its sections are
graded content and may gain items over time — read compliancePercent for the number and treat
the sections as display detail.
Recording playback
audioUrl is either a short-lived signed link to Athos's own archived recording — with
audioUrlExpiresAt set to the moment it stops working, about an hour after the request — or
null, in which case audioUrlExpiresAt is null too and there is nothing to play (a real call
whose recording was not archived, a real call older than 30 days — its archived recording has
been deleted — or the rare case where a recording exists but could not be prepared, which a later
request usually resolves).
Use the link directly in an <audio> element or a download link. Do not store it, email it or
embed it anywhere long-lived: when you need it again, fetch the call again and you get a fresh one.
The link is signed for GET only — a HEAD request answers 403; to inspect the file without
downloading it, send a ranged GET (Range: bytes=0-0). On a real_call it is never the dialer's
own recording URL.