Calls
Build a transcriber testing UI
Every screen of transcriber testing on the REST API, from flagging a caller line to proving a candidate on whole calls, with the requests, responses and errors behind each one.
This guide builds transcriber testing screens into your own product, on the REST API: flag the caller lines a call's transcriber got wrong, score them against banks of candidate transcribers, read the results, and replay the leader over whole calls before you switch. The OnCore dashboard already offers all of this to organization admins (see Test transcribers in the dashboard), and it is built on these same endpoints, so anything it shows can be built from what the API returns. Read the introduction first for what a bank, a control line and a full-call run are. The generated Transcriber Tests API reference has every field of every endpoint.
Before you start#
You need:
- An organization API key, held by your backend. Every request on this page uses it without
X-Tenant-Id, because flags, banks and runs belong to the organization and span its tenants. A tenant key is refused with403 ORG_SCOPE_REQUIRED, and so is an organization key sent with theX-Tenant-Idof one of its tenants (a tenant of another organization gets403 FORBIDDENfirst). Never send the key from a browser. See Organization-level access. - Optionally, who is acting. Requests that create a flag, a bank or a full-call run accept an
X-On-Behalf-Ofheader. Send the signed-in person's name or your own user id; it comes back as the record'screatedBy, which isnullwhen the header was absent. OnCore records it as given and never verifies it. - Recorded calls. A line can be tested only when its call was recorded. Recording is set per assistant; see Recording and transcripts.
- A call's
id. Every{callId}below is theidof a call fromGET /calls, not itscidorcallSid.GET /callsis tenant-scoped, so that one request takesX-Tenant-Idlike any other tenant read.
Success bodies use the standard envelope: the payload sits under data. The examples after the first show only the data object. The Node.js examples share one helper, which returns data and throws with the error object on failure:
export const BASE_URL = "https://core-api.heysadie.ai";
export class OnCoreError extends Error {
constructor(
public status: number,
public code: string,
public details: unknown,
message: string,
) {
super(message);
}
}
export async function oncore<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
Authorization: `ApiKey ${process.env.ONCORE_ORG_API_KEY}`,
"Content-Type": "application/json",
...init.headers,
},
});
if (response.status === 204) return undefined as T;
const body = await response.json();
if (!body.success) {
throw new OnCoreError(response.status, body.error.code, body.error.details, body.error.message);
}
return body.data as T;
}Type names in the examples (CallLines, Turn, BankCard and so on) stand for the response shapes in the reference; generate them from the OpenAPI document this site publishes at https://docs.getoncore.ai/openapi.json, or write them from the examples here. A 400 VALIDATION_ERROR lists each broken field as details: [{ "field": "...", "message": "..." }]. A body that is missing a required field, or has a field that cannot be read as its declared type, is refused before those rules run, with a 400 whose code is INTERNAL_SERVER_ERROR, message Something went wrong! and no details; treat any 400 as a request your form must fix. Every 404 RESOURCE_NOT_FOUND message names what was not found, such as Call not found or Transcriber bank not found.
Three endpoints export CSV with format=csv. They answer text/csv with a Content-Disposition: attachment filename, not JSON, so fetch them without the helper and pass the response through to the browser:
import { BASE_URL, OnCoreError } from "./oncore";
export async function oncoreCsv(path: string): Promise<Response> {
const response = await fetch(`${BASE_URL}${path}`, {
headers: { Authorization: `ApiKey ${process.env.ONCORE_ORG_API_KEY}` },
});
if (!response.ok) {
const body = await response.json();
throw new OnCoreError(response.status, body.error.code, body.error.details, body.error.message);
}
return new Response(response.body, {
headers: {
"Content-Type": response.headers.get("content-type") ?? "text/csv",
"Content-Disposition": response.headers.get("content-disposition") ?? "attachment",
},
});
}The screens and the endpoints behind them#
| Screen | What it shows | Endpoints |
|---|---|---|
| Call transcript, flagging mode | Which lines can be flagged, a player per line, the flag form | GET /turn-flags/calls/{callId}/lines, GET /turn-flags/calls/{callId}/recording-url, GET /turn-flags/tags, POST /turn-flags |
| Results | One card per bank: accuracy per transcriber, the leader, the threshold | GET /transcriber-banks, POST /transcriber-banks |
| Bank detail | The turn-by-turn table, the candidate editor | GET /transcriber-banks/{id}, GET /transcriber-banks/{id}/turns, PUT /transcriber-banks/{id}/candidates |
| Flagged turns | Every flag, filterable, with edit and remove | GET /turn-flags, PATCH /turn-flags/{id}, DELETE /turn-flags/{id} |
| Full-call run | The run dialog, progress, results, the review sample | /full-call-runs/... and /transcriber-banks/{id}/full-call-runs |
The rest of this page builds them in the order a person uses them.
Show a call's lines and which can be flagged#
GET /turn-flags/calls/{callId}/lines returns every caller and assistant line of the call, in order, with whether each can be flagged and the flag already on it.
curl https://core-api.heysadie.ai/turn-flags/calls/0195f1e2-1111-7000-8000-000000000000/lines \
-H "Authorization: ApiKey ORG_API_KEY"{
"success": true,
"data": {
"data": [
{
"messageIndex": 1,
"role": "bot",
"text": "Thanks for calling Riverside Bistro, how can I help?",
"audio": null,
"flaggable": false,
"notFlaggableReason": "not_caller_line",
"flag": null
},
{
"messageIndex": 2,
"role": "user",
"text": "Hi, a table for for at 830 please, the name is Shivon.",
"audio": { "startSeconds": 4.21, "endSeconds": 8.02 },
"flaggable": true,
"notFlaggableReason": null,
"flag": null
}
],
"recording": { "channels": "stereo", "durationSeconds": 94.5 },
"liveTranscriber": { "id": "0195f1e2-2222-7000-8000-000000000000", "name": "Deepgram Nova 2" }
},
"metadata": { "timestamp": "2026-10-05T09:14:03.000Z", "version": "1.0", "path": "/turn-flags/calls/0195f1e2-1111-7000-8000-000000000000/lines" }
}| Field | Meaning |
|---|---|
data[].messageIndex | The line's position in the call's messages. You send it back when you flag |
data[].role | user is the caller, bot is the assistant |
data[].text | What the live transcriber wrote |
data[].audio | Where the line sits in the recording, in seconds, or null |
data[].flaggable / notFlaggableReason | Whether the line can be flagged, and why not |
data[].flag | The turn already on the line: { id, kind }, where kind is flagged or control, else null |
recording | null when the call has no recording. channels is stereo (assistant left, caller right) or mono |
liveTranscriber | The transcriber the call ran on, or null when it is unknown |
Show a "Flag for transcription" control only when recording is not null. In flagging mode, let a person pick only lines with flaggable: true, and put the reason on the others:
notFlaggableReason | Suggested copy |
|---|---|
not_caller_line | Only the caller's lines can be flagged |
no_recording | This call has no recording |
transcriber_unknown | We can't tell which transcriber this call used |
no_audio_bounds | This line has no audio timing |
Those four reasons are the complete list. flaggable describes the line, not whether it is free, so a line that already carries a flag still reads flaggable: true. Check flag first: when it is a flag, show its state and open it for editing rather than offering a new one. The lines response carries only the flag's id and kind; read the rest from GET /turn-flags?callId=... and match on id.
A line can show a control (kind: "control") that was sampled for another flag. Flagging that line replaces the control with your flag: the control is removed from the flag it was sampled for, which gets no replacement, and the control's scores leave the bank.
Name the call's liveTranscriber in a banner, so the person knows whose mistakes they are marking. Once the call's transcript has been removed under data retention, the response is still 200, with an empty data list.
Play one line#
GET /turn-flags/calls/{callId}/recording-url returns a link to the call's recording that works for 10 minutes. Call it from your backend and hand the url to the browser. The link is not your API key, but it does grant access to the recording until it expires, so keep it out of logs.
curl https://core-api.heysadie.ai/turn-flags/calls/0195f1e2-1111-7000-8000-000000000000/recording-url \
-H "Authorization: ApiKey ORG_API_KEY"{
"url": "https://recordings.example.com/0195f1e2-1111-7000-8000-000000000000.ogg?signature=...",
"expiresAt": "2026-10-05T09:24:03.000Z",
"channels": "stereo",
"callerChannel": 1
}Play the line from 0.75 seconds before its audio.startSeconds to 0.75 seconds after its audio.endSeconds. That is the stretch of the recording each candidate transcribed, so a person hears the same moment that was tested. Candidates hear only the caller's channel of a stereo recording; the browser plays both. A media fragment starts playback at the right place; stop it yourself at the end, because browsers do not reliably honour the fragment's end time:
const MARGIN_SECONDS = 0.75;
export function playLine(
player: HTMLAudioElement,
recordingUrl: string,
audio: { startSeconds: number; endSeconds: number },
) {
const from = Math.max(0, audio.startSeconds - MARGIN_SECONDS);
const to = audio.endSeconds + MARGIN_SECONDS;
player.src = `${recordingUrl}#t=${from},${to}`;
player.ontimeupdate = () => {
if (player.currentTime >= to) player.pause();
};
return player.play();
}Cache the link per call until expiresAt, and fetch a new one when it has expired or when playback fails. Use one <audio> element per page, so starting a line stops the last one. The recording plays both channels as recorded, so the assistant may be audible under the caller. callerChannel is the zero-based index of the caller's channel: 1 (right) on a stereo recording, 0 on mono, where both voices share one channel. Stereo recordings are Ogg audio, so check playback in every browser you support.
A call with no recording available for playback answers 404 RESOURCE_NOT_FOUND. 500 RECORDINGS_NOT_CONFIGURED means playback is unavailable on the platform side; disable the player and try again later.
Create a bank#
A bank needs candidates, and candidates come from the transcriber catalogue. GET /transcribers takes the organization key without X-Tenant-Id, like the rest of this page. Each entry carries a language you can filter or group the picker by.
curl "https://core-api.heysadie.ai/transcribers?limit=500" \
-H "Authorization: ApiKey ORG_API_KEY"{
"transcribers": [
{
"id": "0195f1e2-3333-7000-8000-000000000000",
"name": "Deepgram Nova 3",
"provider": "deepgram",
"model": "nova-3",
"language": "en-US",
"tags": null
}
],
"total": 1
}Then create the bank with POST /transcriber-banks:
| Field | Required | Meaning |
|---|---|---|
name | Yes | 1 to 120 characters, unique in your organization ignoring case |
languageLabel | Yes | A display label, 1 to 60 characters, such as en-US or en + fr |
candidateIds | Yes | Transcriber ids, distinct, at most 8, in the order you want them shown. Can be empty and filled later |
threshold | No | The accuracy, from 0 to 1, a candidate must reach to clear the bank. Default 0.6 |
controlLinesPerFlag | No | 0, 1 or 2 control lines sampled with each flag. Default 1 |
curl -X POST https://core-api.heysadie.ai/transcriber-banks \
-H "Authorization: ApiKey ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "English",
"languageLabel": "en-US",
"threshold": 0.6,
"controlLinesPerFlag": 1,
"candidateIds": [
"0195f1e2-3333-7000-8000-000000000000",
"0195f1e2-4444-7000-8000-000000000000"
]
}'{
"bank": {
"id": "0195f1e2-5555-7000-8000-000000000000",
"organizationId": "0195f1e2-0000-7000-8000-000000000000",
"name": "English",
"languageLabel": "en-US",
"threshold": 0.6,
"controlLinesPerFlag": 1,
"candidates": [
{ "id": "0195f1e2-3333-7000-8000-000000000000", "name": "Deepgram Nova 3" },
{ "id": "0195f1e2-4444-7000-8000-000000000000", "name": "AssemblyAI Universal" }
],
"createdBy": null,
"createdAt": "2026-10-05T09:15:00.000Z",
"updatedAt": "2026-10-05T09:15:00.000Z"
}
}A new bank holds no lines. Lines join it when someone flags them into it.
PATCH /transcriber-banks/{id} changes name, languageLabel, threshold or controlLinesPerFlag, at least one of them, with the same rules as creation, and answers { bank }. threshold is stored to three decimals. A new controlLinesPerFlag applies to flags made from then on; existing flags keep the controls they have. Candidates have their own endpoint, covered in Change a bank's candidates.
| Error | When | What your UI should do |
|---|---|---|
409 BANK_NAME_EXISTS | Another bank has this name | Keep the dialog open and ask for another name |
422 BANK_LIMIT | More than details.limit candidates | Stop ticking at 8 so this never reaches the API |
400 VALIDATION_ERROR | A field broke a rule, or a candidate id is repeated | Show each message beside its field |
404 RESOURCE_NOT_FOUND | A candidate id is not an active transcriber in the catalogue; details.transcriberIds lists them | Reload the catalogue and drop those ids |
Flag a line#
Fill the tag chips from GET /turn-flags/tags. Its payload is { "data": ["wrong_word", "name", "time", "party_size", "menu_item", "spelling", "dropped_words", "other"] }, a list of suggestions. Any tag of 1 to 32 lowercase letters or underscores is accepted, up to 8 per flag, so your form can offer its own.
The flag form needs four things from the person: the bank, what the caller actually said, the tags, and an optional note. Prefill "what the caller said" with the line's text, so they only correct the words that are wrong. Remember the last bank they chose, and offer "Not in a bank yet" as well. Then send POST /turn-flags:
| Field | Required | Meaning |
|---|---|---|
callId | Yes | The call's id |
messageIndex | Yes | The line's messageIndex from the lines response |
expectedText | Yes | What the caller said, word for word. 1 to 2000 characters |
errorTags | Yes | The tags, possibly empty |
comment | No | Up to 2000 characters. Blank is stored as null |
bankId | No | The bank the flag joins. Leave it out, or send null, to keep the flag in no bank |
curl -X POST https://core-api.heysadie.ai/turn-flags \
-H "Authorization: ApiKey ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"callId": "0195f1e2-1111-7000-8000-000000000000",
"messageIndex": 2,
"expectedText": "Hi, a table for four at eight thirty please, the name is Siobhan.",
"errorTags": ["name", "party_size"],
"comment": "Regular customer, Irish spelling",
"bankId": "0195f1e2-5555-7000-8000-000000000000"
}'{
"flag": {
"id": "0195f1e2-6666-7000-8000-000000000000",
"organizationId": "0195f1e2-0000-7000-8000-000000000000",
"tenantId": "0195f1e2-7777-7000-8000-000000000000",
"callId": "0195f1e2-1111-7000-8000-000000000000",
"assistantId": "0195f1e2-8888-7000-8000-000000000000",
"messageIndex": 2,
"bankId": "0195f1e2-5555-7000-8000-000000000000",
"kind": "flagged",
"heardText": "Hi, a table for for at 830 please, the name is Shivon.",
"expectedText": "Hi, a table for four at eight thirty please, the name is Siobhan.",
"errorTags": ["name", "party_size"],
"comment": "Regular customer, Irish spelling",
"audio": { "startSeconds": 4.21, "endSeconds": 8.02 },
"evaluation": "pending",
"controlForTurnId": null,
"fullCallRunId": null,
"tieBreak": null,
"label": null,
"review": null,
"calibration": false,
"createdBy": null,
"createdAt": "2026-10-05T09:16:12.000Z",
"updatedAt": "2026-10-05T09:16:12.000Z",
"liveTranscriber": { "id": "0195f1e2-2222-7000-8000-000000000000", "name": "Deepgram Nova 2" },
"evaluationProgress": null
},
"controls": [
{
"id": "0195f1e2-9999-7000-8000-000000000000",
"kind": "control",
"messageIndex": 6,
"heardText": "Yes, that's right.",
"expectedText": "Yes, that's right.",
"controlForTurnId": "0195f1e2-6666-7000-8000-000000000000",
"evaluation": "pending"
}
]
}The control above is shortened; every turn has the same fields as flag. These are the fields your screens use:
| Field | Meaning |
|---|---|
kind | flagged for a person's flag, control for a sampled control line, full_call for a line of a full-call run |
heardText | What the live transcriber wrote, frozen when the turn was made |
expectedText | What was said. A control's is its heard text |
bankId | The bank, or null while the turn is in no bank |
evaluation | pending, scored or failed |
evaluationProgress | The job scoring the turn, or null. See the next section |
liveTranscriber | The transcriber the call ran on. Its stored text is the turn's production attempt |
controlForTurnId | On a control, the flag it was sampled for |
tieBreak | When candidates tied, the judge's pick: { transcriberId, reason } |
Flagging into a bank writes the live transcriber's attempt from the stored transcript at no cost, samples the bank's control lines from other caller lines of the same call, and queues every candidate on the flag and its controls. Flagging into no bank stores the flag with no controls and queues nothing. You can move it into a bank later, but controls are only sampled at flag time, so a flag moved later brings none.
createdBy holds the X-On-Behalf-Of header the flagging request sent, or null. Send it to show who flagged each line.
POST /turn-flags takes no Idempotency-Key. A line holds one flag, so retrying a request that already succeeded answers 409 TURN_FLAG_EXISTS with the flag's id: treat that as success and open the existing flag. The other checks run first, so a retry can still meet 422, 404 or 429 instead, and existingFlagId is null if that flag was removed in the meantime.
| Error | When | What your UI should do |
|---|---|---|
409 TURN_FLAG_EXISTS | The line already has a flag; details.existingFlagId names it, or is null if it was just removed | Open that flag for editing, or reload the lines |
422 LINE_NOT_FLAGGABLE | details.reason is one of the reasons in the table above | Show the reason on the line and close the form |
404 RESOURCE_NOT_FOUND | The call is not in your organization, or bankId is not a bank of it | Reload the banks; the bank may have been deleted |
429 BACKLOG_FULL | Your organization has more than details.limit jobs waiting. Nothing was written, and there is no Retry-After | Keep the form's text, say scoring is busy, and offer to retry in a minute. Flagging into no bank, or into a bank with no candidates, still works |
400 VALIDATION_ERROR | A field broke a rule; details lists the fields | Show each message beside its field |
Show scoring progress#
Scoring runs in the background. Each organization has a few jobs running at a time, and everything else waits its turn, so a flag can wait before it starts. Poll the call's turns to follow it:
curl "https://core-api.heysadie.ai/turn-flags?callId=0195f1e2-1111-7000-8000-000000000000" \
-H "Authorization: ApiKey ORG_API_KEY"Each turn's evaluationProgress describes the job that is scoring it:
{ "stage": "scoring", "done": 12, "total": 40 }stage | Meaning | Suggested copy |
|---|---|---|
waiting | The job is queued and no worker has started it | "Waiting to score" |
scoring | A worker is transcribing | "Scoring, 12 of 40" |
done and total count every transcription in the job, which can hold other lines and candidates too, so show them as the job's progress, not the line's. done stays 0 until the worker first reports. evaluationProgress is always null on the POST /turn-flags response, because the job is queued after the turns are written; the next read has it.
A turn moves through these states:
evaluation | How it gets there |
|---|---|
pending | Flagged or moved into a bank that has candidates, so a job was queued for it. Also every flag in no bank: it stays pending, with no job, until it joins a bank |
scored | Every queued candidate reported, with text or with an error. A turn that joins a bank with no candidates, or whose candidates have all run already, is scored at once |
failed | Its job stopped before reporting, for example after a timeout. It goes back to scored when a later backfill reports on it |
Poll every 5 seconds while any turn you show has a non-null evaluationProgress, or is pending with a non-null bankId (right after flagging, before the first read shows its job). Stop when neither holds. A backfill queues work without moving a turn back to pending, so a scored or failed turn with a non-null evaluationProgress is being worked on again. A turn in no bank never starts on its own, so do not poll for it. A turn can sit in waiting for a while when your organization has a backlog; after a few minutes, slow the polling down and say it is still queued rather than giving up. For a failed turn, see Change a bank's candidates for how to retry.
Read the results cards#
GET /transcriber-banks returns one card per bank, sorted by name. windowDays sets how many days of flagged and control lines, by when they were created, the scores cover: 1 to 90, default 30.
curl "https://core-api.heysadie.ai/transcriber-banks?windowDays=30" \
-H "Authorization: ApiKey ORG_API_KEY"{
"id": "0195f1e2-5555-7000-8000-000000000000",
"name": "English",
"languageLabel": "en-US",
"threshold": 0.6,
"controlLinesPerFlag": 1,
"candidates": [
{ "id": "0195f1e2-3333-7000-8000-000000000000", "name": "Deepgram Nova 3" },
{ "id": "0195f1e2-4444-7000-8000-000000000000", "name": "AssemblyAI Universal" }
],
"live": [
{ "transcriberId": "0195f1e2-2222-7000-8000-000000000000", "name": "Deepgram Nova 2", "lines": 48 }
],
"window": { "days": 30, "turns": 48, "flagged": 24, "controls": 24 },
"scores": [
{ "transcriberId": "0195f1e2-3333-7000-8000-000000000000", "name": "Deepgram Nova 3", "role": "candidate", "accuracy": 0.75, "matched": 36, "scored": 48, "errors": 0, "meanWer": 0.0612 },
{ "transcriberId": "0195f1e2-4444-7000-8000-000000000000", "name": "AssemblyAI Universal", "role": "candidate", "accuracy": 0.625, "matched": 30, "scored": 48, "errors": 0, "meanWer": 0.0844 },
{ "transcriberId": "0195f1e2-2222-7000-8000-000000000000", "name": "Deepgram Nova 2", "role": "production", "accuracy": 0.5, "matched": 24, "scored": 48, "errors": 0, "meanWer": 0.1325 }
],
"leader": { "transcriberId": "0195f1e2-3333-7000-8000-000000000000", "accuracy": 0.75, "clearsThreshold": true }
}The card also carries the bank's organizationId, createdBy, createdAt and updatedAt. To render it:
- One bar per
scoresrow. Candidates come first, in the bank's order, then oneproductionrow per live transcriber, which is what your callers' calls actually ran on. Label production rows "(live)" and show them muted, so the comparison reads at a glance. Draw the bank'sthresholdas a tick on every bar. accuracyismatched / scored, ornullwhile nothing is scored. Show "No scores yet" rather than 0%.errorscounts attempts that failed to transcribe; they are left out ofscored.meanWeris the average word error rate over scored attempts.- The badge. When
leader.clearsThresholdis true, "Deepgram Nova 3 clears 60%". Whenleaderis present but does not clear it, or isnull, "Nobody clears 60%". - The subline lists
live: the transcribers the bank's lines came from, with their line counts, most first. - The window picker. The dashboard offers 7, 30 and 90 days.
Cards are cached for up to 60 seconds per window, so a change, yours or a newly scored line, can take up to a minute to appear on them. On a bank's own page, read GET /transcriber-banks/{id}?windowDays=30 instead: it returns the same card under bank, read fresh.
Add format=csv to export the cards as a CSV attachment, one row per bank and score.
Render the turn-by-turn table#
GET /transcriber-banks/{id}/turns returns the bank's flagged and control lines in the window, newest first, with every attempt on each.
curl "https://core-api.heysadie.ai/transcriber-banks/0195f1e2-5555-7000-8000-000000000000/turns?windowDays=30&limit=50" \
-H "Authorization: ApiKey ORG_API_KEY"{
"columns": [
{ "key": "candidate:0195f1e2-3333-7000-8000-000000000000", "transcriberId": "0195f1e2-3333-7000-8000-000000000000", "name": "Deepgram Nova 3", "role": "candidate" },
{ "key": "candidate:0195f1e2-4444-7000-8000-000000000000", "transcriberId": "0195f1e2-4444-7000-8000-000000000000", "name": "AssemblyAI Universal", "role": "candidate" },
{ "key": "production:0195f1e2-2222-7000-8000-000000000000", "transcriberId": "0195f1e2-2222-7000-8000-000000000000", "name": "Deepgram Nova 2", "role": "production" }
],
"data": [
{
"flag": { "id": "0195f1e2-6666-7000-8000-000000000000", "kind": "flagged", "expectedText": "Hi, a table for four at eight thirty please, the name is Siobhan.", "errorTags": ["name", "party_size"], "evaluation": "scored" },
"tenant": { "id": "0195f1e2-7777-7000-8000-000000000000", "name": "Riverside Bistro" },
"assistant": { "id": "0195f1e2-8888-7000-8000-000000000000", "name": "Front desk" },
"attempts": {
"candidate:0195f1e2-3333-7000-8000-000000000000": {
"turnId": "0195f1e2-6666-7000-8000-000000000000",
"transcriberId": "0195f1e2-3333-7000-8000-000000000000",
"role": "candidate",
"sttModel": "deepgram/nova-3",
"text": "Hi, a table for four at eight thirty please, the name is Siobhan.",
"error": null,
"match": true,
"wer": 0,
"notes": [],
"identicalToProduction": null,
"audioSeconds": 6.81
},
"production:0195f1e2-2222-7000-8000-000000000000": {
"turnId": "0195f1e2-6666-7000-8000-000000000000",
"transcriberId": "0195f1e2-2222-7000-8000-000000000000",
"role": "production",
"sttModel": "deepgram/nova-2",
"text": "Hi, a table for for at 830 please, the name is Shivon.",
"error": null,
"match": false,
"wer": 0.3077,
"notes": [],
"identicalToProduction": null,
"audioSeconds": 0
}
},
"leader": { "transcriberIds": ["0195f1e2-3333-7000-8000-000000000000"], "tieBreakPick": null, "reason": null }
}
],
"nextCursor": "eyJpZCI6IjAxOTVmMWUyLTY2NjYtNzAwMC04MDAwLTAwMDAwMDAwMDAwMCJ9"
}The flag in each row is a full turn; it is shortened above. Build the table from columns, in order: the bank's candidates, then the live transcribers of the page's lines. Each column's key is <role>:<transcriberId>, and it is also the key of that column's cell in a row's attempts. Look cells up by key, never by transcriber id alone, because one transcriber can be both a candidate and a call's live transcriber.
Check a cell's state in this order:
| Cell state | How to tell | What to show |
|---|---|---|
| Not run yet | No attempt under the key | "Scoring" while flag.evaluation is pending or flag.evaluationProgress is not null. On a failed turn, "Not scored" with a retry (see Change a bank's candidates). Otherwise "n/a" |
| Failed | error is not null. Its text, match and wer are null | The error, without a score. A recording that could not be fetched reads could not load the recording, or could not load the recording (HTTP 403) with the status |
| Matched | match: true | A tick. notes can hold case and punctuation: show a small note that the raw text differed only that way |
| Missed | match: false | The attempt's text, highlighted as wrong, with its wer beneath |
identicalToProduction is always null in a bank's table; it belongs to full-call runs. attempts can also hold keys of former candidates that are not in columns: render cells from columns only.
Each page lists only the live transcribers of its own lines, so a later page can bring a production column the first did not have. When you append pages, merge columns by key.
Each row's leader.transcriberIds lists the candidates that matched that line, in bank order, and is empty when none did. This is the line's own result, not the bank's leader. When two or more matched with different raw text, leader.tieBreakPick is usually the one the judge preferred and leader.reason its one-sentence reason (the same pick as the turn's tieBreak): mark that cell "judge pick" with the reason as a tooltip. The pick never changes a score. It can be null on a tie when the judge could not decide; it only compares candidates scored together, not one added later by a backfill; and it is not redone when the expected text changes, so show it only on a candidate that still matches.
Show flag.kind too. A control row is a line nobody flagged: its expected text is what the live transcriber heard, so the production cell matches unless someone has since edited that text, and a candidate that misses it is getting an ordinary line wrong.
Filter with kind (flagged or control) and tag (one error tag). limit takes 1 to 200, default 50. When nextCursor is not null, pass it back as cursor for the next page, for example behind a "Load more" button. A cursor this API did not issue answers 400 VALIDATION_ERROR.
For export, add format=csv and fetch it with oncoreCsv. Each CSV row is one line under one column: the line's turnId, kind, callId, messageIndex, tenant and assistant, heardText, expectedText, errorTags, comment, evaluation and createdAt, then the column's columnKey, transcriberId, transcriberName and role, the attempt's text, error, match, wer, notes and audioSeconds, and whether that candidate matched (leader) or was the judge's pick (tieBreakPick). Lists are joined with ;. The export covers the filters you send over the whole window, up to 5000 lines, and ignores limit and cursor; nothing in the file says it was cut. To tell, count the distinct turnId values: for an export with no kind or tag filter, fewer than the bank card's window.turns for the same window means the file was capped at 5000 lines. The cards export (GET /transcriber-banks?format=csv) has one row per bank and score, with the bank's settings and window counts, the score's fields, and leader and clearsThreshold.
Manage flags#
GET /turn-flags lists the organization's flagged and control lines, newest first, across every bank. Lines of full-call runs are listed under their run instead. It filters by bankId, callId, kind, evaluation (pending, scored or failed) and unassigned=true for lines in no bank, which cannot be combined with bankId. It pages with limit (1 to 200, default 50) and cursor, like the turn table. This is the Flagged turns screen. Rows carry tenantId and assistantId but no names: map tenants with GET /organization/tenants, which lists your organization's tenants as { id, name }. Controls come back as their own rows; group each under its flag with controlForTurnId, or filter them out with kind=flagged.
PATCH /turn-flags/{id} edits a flag or a control. Send at least one of these:
| Field | Effect |
|---|---|
expectedText | Rescores the line's stored attempts at once, at no transcription cost |
errorTags | Replaces the tags |
comment | Replaces the note. null or blank clears it |
bankId | Moves the line, and a flag's controls, to that bank and queues the candidates each has not run yet. null takes them out of any bank and leaves their evaluation as it was. On a control, it moves that control alone |
curl -X PATCH https://core-api.heysadie.ai/turn-flags/0195f1e2-6666-7000-8000-000000000000 \
-H "Authorization: ApiKey ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "bankId": "0195f1e2-5555-7000-8000-000000000000" }'It answers { flag } with the updated turn. A control can be edited too, its expected text included.
DELETE /turn-flags/{id} answers 204. Deleting a flag also deletes its controls, and deleted lines leave every score. Scoring already queued for them is not cancelled, so delete after scoring finishes if you want to avoid paying for it. Offer it as "Remove flag" when a person reopens a flagged line.
Change a bank's candidates#
Adding a candidate to a bank can replay it on every line the bank already holds. Before saving, show what that costs with POST /transcriber-banks/{id}/candidates/estimate. It saves nothing and queues nothing, so call it each time a box is ticked:
curl -X POST "https://core-api.heysadie.ai/transcriber-banks/0195f1e2-5555-7000-8000-000000000000/candidates/estimate?windowDays=30" \
-H "Authorization: ApiKey ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "transcriberIds": ["0195f1e2-3333-7000-8000-000000000000", "0195f1e2-4444-7000-8000-000000000000", "0195f1e2-aaaa-7000-8000-000000000000"] }'{ "turns": 48, "audioSeconds": 398.4, "estimatedCostUsd": 0.0398 }turns counts the window's lines missing at least one of the listed candidates, and audioSeconds the audio those replays would transcribe. Show it as "Score Speechmatics on the 48 lines already flagged, 398 s of audio, about $0.04".
Save with PUT /transcriber-banks/{id}/candidates. Both transcriberIds (named differently from candidateIds on creation) and backfill are required. The list replaces the bank's candidates and sets their display order. backfill: true queues the missing replays; false saves the list and queues nothing. Both answer the bank and the same estimate, plus the queued jobIds:
curl -X PUT "https://core-api.heysadie.ai/transcriber-banks/0195f1e2-5555-7000-8000-000000000000/candidates?windowDays=30" \
-H "Authorization: ApiKey ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "transcriberIds": ["0195f1e2-3333-7000-8000-000000000000", "0195f1e2-4444-7000-8000-000000000000", "0195f1e2-aaaa-7000-8000-000000000000"], "backfill": true }'{
"bank": { "id": "0195f1e2-5555-7000-8000-000000000000", "name": "English", "candidates": [{ "id": "0195f1e2-3333-7000-8000-000000000000", "name": "Deepgram Nova 3" }] },
"backfill": { "turns": 48, "audioSeconds": 398.4, "estimatedCostUsd": 0.0398, "jobIds": ["0195f1e2-eeee-7000-8000-000000000000"] }
}bank is the plain bank, as creation returns it, shortened above; read the card again for scores. jobIds identifies the queued jobs for your logs. No endpoint reads a job; progress shows on each turn's evaluationProgress.
A backfill only covers lines in the windowDays you send, by when they were flagged, so a line older than that window shows no attempt for the new candidate if someone later widens the cards to 90 days. Send the widest window you report on. It queues a candidate on a line only where it has no attempt and no job already running it, so saving twice never pays twice. That also makes it the retry for lines whose evaluation is failed: save the same list with backfill: true and the transcriptions that never landed are queued again. An attempt that came back with an error is kept as an error and is not retried. Removing a candidate takes it off the cards and the table; adding it back shows its stored attempts again without running them twice.
The errors match bank creation: 422 BANK_LIMIT past 8 candidates, and 404 RESOURCE_NOT_FOUND with details.transcriberIds for ids that are not active transcribers in the catalogue. With backfill: true, 429 BACKLOG_FULL means your organization has more than details.limit jobs waiting and nothing was saved: retry once some finish, or save the list now with backfill: false and backfill later. The estimate endpoint never returns it.
Start a full-call run#
A run starts from a flagged line: the line's call is the first call, and the bank's leader is the candidate. Offer "Test on the full call" on each flagged row of the turn table and of the Flagged turns screen. A flag in no bank has no bank to run under, so hide the button on it. Starting from a flagged line is how the dashboard frames a run, not a rule of the API: any calls of your organization will do. The run dialog then needs:
- The candidate. Default to
leader.transcriberIdfrom the bank's card, and let the person pick any other candidate of the bank. SendcandidateTranscriberIdevery time: leaving it out uses the leader over the last 30 days, which is not the leader your card shows when it reads another window. - The calls. Start with the flagged line's
callId. Let the person add more, up to 10 in total, and remove any of them. - The estimate, refreshed as the selection changes.
- A start button that carries the price, such as "Start run, $0.42".
State in the dialog that only the caller's lines are replayed, never the assistant's.
To add calls, list the ones a run can replay with GET /full-call-runs/eligible-calls. It takes a tenantId: the flagged line's tenantId, or a tenant from GET /organization/tenants, which lists your organization's tenants as { id, name }. It returns that tenant's calls of the last 30 days that have a recording, a caller line with audio timing and a known live transcriber, newest first. limit takes 1 to 20, default 20, and nextCursor pages. A tenant outside your organization answers 404.
curl "https://core-api.heysadie.ai/full-call-runs/eligible-calls?tenantId=0195f1e2-7777-7000-8000-000000000000&limit=20" \
-H "Authorization: ApiKey ORG_API_KEY"{
"callId": "0195f1e2-bbbb-7000-8000-000000000000",
"assistant": { "id": "0195f1e2-8888-7000-8000-000000000000", "name": "Front desk" },
"startedAt": "2026-10-04T18:42:10.000Z",
"durationSeconds": 131.2,
"callerLines": 14,
"liveTranscriber": { "id": "0195f1e2-2222-7000-8000-000000000000", "name": "Deepgram Nova 2" }
}The dashboard offers calls of the flagged line's tenant. The API accepts any calls of your organization.
Price the selection with POST /transcriber-banks/{id}/full-call-runs/estimate, using the body the run will take:
| Field | Required | Meaning |
|---|---|---|
callIds | Yes | 1 to 10 distinct call ids, replayed in this order |
candidateTranscriberId | No | A candidate of the bank. Defaults to the bank's leader over the last 30 days |
calibrationTurns | No | Lines on which the live transcriber is replayed too, to measure how well a replay reproduces the live text. 0 to 200, default 20 |
curl -X POST https://core-api.heysadie.ai/transcriber-banks/0195f1e2-5555-7000-8000-000000000000/full-call-runs/estimate \
-H "Authorization: ApiKey ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "callIds": ["0195f1e2-1111-7000-8000-000000000000", "0195f1e2-bbbb-7000-8000-000000000000"] }'{ "calls": 2, "callerTurns": 25, "audioSeconds": 211.6, "estimatedCostUsd": 0.0212 }callerTurns is the number of caller lines that will be replayed, and audioSeconds covers the candidate on every one of them plus the calibration sample. The live transcriber's stored text costs nothing.
Start the run with POST /transcriber-banks/{id}/full-call-runs, the same body, and an Idempotency-Key header of 1 to 255 characters. The header is optional, but send it. Generate one key each time the dialog opens and send it with every attempt from that dialog: a repeated key answers 201 with the run it already created and starts nothing, so a double click or a retried request after a timeout cannot start two runs. The key is matched across your organization alone, not with the body or the bank, and stays tied to that run for good, so never reuse one for a different run. A request that was refused created no run, so after a 4xx the same key can carry a corrected selection.
curl -X POST https://core-api.heysadie.ai/transcriber-banks/0195f1e2-5555-7000-8000-000000000000/full-call-runs \
-H "Authorization: ApiKey ORG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2d4e-run-dialog-1" \
-d '{ "callIds": ["0195f1e2-1111-7000-8000-000000000000", "0195f1e2-bbbb-7000-8000-000000000000"] }'{
"run": {
"id": "0195f1e2-cccc-7000-8000-000000000000",
"organizationId": "0195f1e2-0000-7000-8000-000000000000",
"bankId": "0195f1e2-5555-7000-8000-000000000000",
"candidateTranscriberId": "0195f1e2-3333-7000-8000-000000000000",
"selection": { "callIds": ["0195f1e2-1111-7000-8000-000000000000", "0195f1e2-bbbb-7000-8000-000000000000"] },
"calibrationTurns": 20,
"callCount": 2,
"estimate": { "calls": 2, "callerTurns": 25, "audioSeconds": 211.6, "estimatedCostUsd": 0.0212 },
"status": "queued",
"analysisSampled": false,
"error": null,
"createdBy": null,
"createdAt": "2026-10-05T09:30:00.000Z",
"startedAt": null,
"finishedAt": null
}
}The estimate and the start refuse the same things, so the dialog can show a problem before anyone clicks start:
| Error | When | What your UI should do |
|---|---|---|
422 CALL_NOT_RUNNABLE | A call cannot be replayed. details.callId names the first such call in your order, and details.reason is no_recording, no_caller_audio (no caller line has audio timing) or transcriber_unknown | Mark that call in the list with the reason and let the person remove it |
422 CANDIDATE_REQUIRED | No candidate was sent and the bank has no leader yet | Ask the person to pick a candidate |
422 NOT_A_BANK_CANDIDATE | details.candidateTranscriberId is not a candidate of the bank, or has left the catalogue | Reload the bank and reset the picker |
404 RESOURCE_NOT_FOUND | A call or the bank is not in your organization | Remove the call, or reload the banks |
409 RUN_ACTIVE | Another run of your organization is queued or running; details.runId names it. A run that is only analyzing does not block | Link to that run and say one run goes at a time. The estimate never returns this |
400 VALIDATION_ERROR | No calls, more than 10, a repeated call id, or a bad field | Keep the selection between 1 and 10 distinct calls |
Follow a run's progress#
Poll GET /full-call-runs/{id} while the run is going. While run.status is queued, running or analyzing, the response carries a Retry-After: 5 header: wait that many seconds before the next read. Stop on a finished status (completed, cancelled or error), and treat an error response as an error, not as the end. If the browser polls through your backend, poll on status there or forward the header.
curl -i https://core-api.heysadie.ai/full-call-runs/0195f1e2-cccc-7000-8000-000000000000 \
-H "Authorization: ApiKey ORG_API_KEY"run.status | Meaning | What to show |
|---|---|---|
queued | Waiting for a worker | "Waiting to start" |
running | The candidate is replaying the caller lines | A bar from progress.done out of progress.total |
analyzing | The judge is labeling the lines that differ | "Analyzing differences" |
completed | Finished | The results below |
cancelled | Someone cancelled it | Its partial results, marked as cancelled |
error | Every transcription job of the run ended without results (for example, timed out), or the analysis failed | run.error, a human-readable message. Do not branch on its wording; see below |
progress.done counts the caller lines the candidate has finished, with text or with an error, out of progress.total, which equals totals.callerTurns. It reads 0 while the run is queued. Offer "Cancel run" while it is going.
The API has no field saying why a run ended in error, so decide the next action from progress. When progress.done is 0, nothing was transcribed and there is nothing to analyze: offer to start a new run. When progress.done is above 0, the transcriptions landed and the analysis is what failed: offer Rescore with relabel, and a new run as the fallback if that fails too.
Read a run's results#
The same GET /full-call-runs/{id} response holds everything the results screen needs. Totals are computed on every read, so they are always current.
{
"run": { "id": "0195f1e2-cccc-7000-8000-000000000000", "status": "completed", "analysisSampled": false },
"totals": {
"calls": 2,
"callerTurns": 25,
"identical": 19,
"differing": 6,
"audioPathAgreement": 0.95,
"labeled": { "candidateRight": 3, "productionRight": 1, "sameMeaning": 2, "bothWrong": 0 },
"reviewed": { "candidateRight": 2, "productionRight": 0, "sameMeaning": 1, "bothWrong": 0 },
"fixed": 3,
"introduced": 1,
"introducedEstimate": 1
},
"progress": { "done": 25, "total": 25 },
"patterns": [
{
"title": "Drops the last word after a pause",
"example": { "production": "a table for two tomorrow", "candidate": "a table for two" },
"turns": 1,
"turnIds": ["0195f1e2-dddd-7000-8000-000000000000"],
"note": "Short trailing words after a pause are cut off."
}
],
"verdict": "Nova 3 fixes three name and number errors and introduces one dropped word. Worth switching for this assistant.",
"cost": { "estimatedUsd": 0.0212, "actualUsd": 0.0209 }
}The run above is shortened; it is the full run object. Lay the screen out top to bottom:
- The verdict, labelled as written by the judge. It is
nulluntil the analysis finishes, and can be empty if the judge produced none. - Five tiles:
totals.calls,totals.callerTurns,totals.differing,totals.fixed(lines the candidate got right and production got wrong) andtotals.introduced(the reverse). On a line that has a flag, right and wrong are decided by its expected text; elsewhere by the person's review when there is one, else by the judge's label. - Where they disagree:
identicalagainstdiffering, then thelabeledcounts. A differing line with a flag is labeled from its expected text; the judge labels the rest.totals.reviewedcounts people's reviews on differing lines, any line of the run, not only the sample. For "N of M checked", use the review queue'sreviewedandsampled, which both count only the sample, so N never exceeds M. A review outranks the judge's label infixedandintroducedonly on lines without a flag. A line the candidate failed to transcribe counts as neither identical nor differing, so the two can add up to less thancallerTurns. ShowaudioPathAgreementas the share of calibration replays that reproduced the live text. It isnulluntil a replay succeeds. We suggest a warning below 90%, because below that the recording itself explains part of the disagreement. - What the candidate broke: one entry per pattern, with its
title, theexamplepair,turns(how many lines) andnote.turnIdsare the run's lines in the pattern; match them against theturn.idof rows from the line-by-line list, which has no filter by id. - Cost:
cost.estimatedUsd, andcost.actualUsdonce jobs are priced (it isnullbefore). The estimate is speech-to-text only, while the actual cost also includes the judge's analysis, so it can come out higher.
When run.analysisSampled is true, the run had more differing lines than the judge labels in one analysis (2000), and it labeled a sample. introducedEstimate scales introduced up to all differing lines; show it beside introduced in that case. It is null while nothing is judged. fixed has no estimate.
Call by call. GET /full-call-runs/{id}/calls?sort=disagreement lists the run's calls, most differing first, then most introduced; without sort the newest call comes first. disagreement is the only sort. It pages with limit (1 to 200, default 50) and nextCursor. Each row:
{
"callId": "0195f1e2-bbbb-7000-8000-000000000000",
"assistant": { "id": "0195f1e2-8888-7000-8000-000000000000", "name": "Front desk" },
"tenant": { "id": "0195f1e2-7777-7000-8000-000000000000", "name": "Riverside Bistro" },
"durationSeconds": 131.2,
"callerTurns": 14,
"differing": 4,
"introduced": 1
}Line by line. GET /full-call-runs/{id}/turns returns the run's lines call by call, in the shape of the bank's turn table: columns, then data rows of { turn, attempts, identical, label, review }. The columns are the candidate, the live transcribers, and a calibration column for each live transcriber replayed on the sample. identical is whether the candidate's text equals production after normalizing, null until the candidate's attempt arrives. label is the line's label (from its expected text when it has a flag, else the judge's) and review the person's, each candidate_right, production_right, same_meaning, both_wrong or null. A calibration column's key is calibration:<transcriberId>. A line that had a flag carries that flag's text in turn.expectedText and is scored against it; on other lines expectedText is null. Render cells by line:
| Line | Cells |
|---|---|
Has a flag (turn.expectedText set) | As in the bank's table: match, wer and notes against the expected text |
| No flag | match and wer are null. Show the candidate's text, marked when its identicalToProduction is false, and the row's label and review as badges |
| Calibration column | The live transcriber run again over the recording. identicalToProduction says whether it reproduced the stored live text; these cells feed audioPathAgreement and are not part of the comparison |
As in the bank's table, check error before anything else, and treat a missing attempt as not run yet. Filter with differing=true or false, label (one of the four), reviewed=true or false, and callId. format=csv exports up to 5000 lines with the same columns as the bank's export plus calibration, identicalToProduction, identical, label and review.
GET /transcriber-banks/{id}/full-call-runs lists a bank's runs, newest first, for a runs list. The summaries carry no totals, so show each run's candidate, callCount, status, estimate.estimatedCostUsd and createdAt. A run names its candidate by candidateTranscriberId only: take the name from the bank's candidates, or from the candidate column of the run's line list.
The calls, line-by-line and review-queue reads also send Retry-After: 5 while the run is in progress, because their contents can still change.
Check the review sample#
The analysis sets aside a sample of the labeled lines, up to 120, drawn from each label in proportion to its count, for a person to check. GET /full-call-runs/{id}/review-queue returns the next unreviewed ones, in a stable order, with sampled and reviewed counts. limit takes 1 to 120, default 20.
{
"data": [
{
"turn": { "id": "0195f1e2-dddd-7000-8000-000000000000", "callId": "0195f1e2-bbbb-7000-8000-000000000000", "messageIndex": 9, "kind": "full_call", "heardText": "a table for two tomorrow", "label": "production_right", "review": null },
"attempts": {
"candidate:0195f1e2-3333-7000-8000-000000000000": { "role": "candidate", "text": "a table for two", "identicalToProduction": false }
},
"identical": false,
"label": "production_right",
"review": null
}
],
"sampled": 6,
"reviewed": 3
}The turn and attempts are shortened above. Show one line at a time: a play button (the turn's callId and audio work with the player above), production's text, the candidate's text, and four buttons. Each posts the person's verdict:
curl -X POST https://core-api.heysadie.ai/full-call-runs/0195f1e2-cccc-7000-8000-000000000000/reviews \
-H "Authorization: ApiKey ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "callId": "0195f1e2-bbbb-7000-8000-000000000000", "messageIndex": 9, "verdict": "production_right" }'verdict is candidate_right, production_right, same_meaning or both_wrong. The response is 201 with the updated turn; reviewing a line again overwrites the verdict, and a review cannot be cleared. Any line of the run can be reviewed, not only sampled ones, and 404 RESOURCE_NOT_FOUND means the run has no line at that callId and messageIndex. Then read the queue again for the next line. Show progress as "3 of 6 checked", from reviewed and sampled. Before the analysis has run, sampled is 0; once every sampled line is reviewed, data is empty and reviewed equals sampled.
Rescore or cancel a run#
POST /full-call-runs/{id}/rescore recomputes every line of the run from the stored transcripts at no transcription cost. It first copies the current expected text of any flag on a run's line, so after correcting a flag, a rescore carries the correction into the run. Send { "relabel": true } to also re-run the judge's labels, patterns and verdict, which costs language-model tokens; the run goes back to analyzing. Offer Relabel only when run.status is completed or error. A run still queued, running or analyzing answers 409 RUN_ACTIVE: wait for it to finish. A cancelled run answers 409 RUN_CANCELLED, because it has nothing to relabel: offer to start a new run instead. Both carry this run's own id in details.runId. Until the new analysis lands, the run keeps its old patterns and verdict. When it lands, it draws the review sample again from the new labels, replacing the old sample, and keeps every review already given.
POST /full-call-runs/{id}/cancel stops a queued, running or analyzing run and its unfinished work. A run that already finished is returned unchanged. Both endpoints answer 200 with { run }, and a plain rescore has finished by the time it answers, on a run of any status.
Errors at a glance#
Every error uses the standard envelope. Branch on error.code.
| Status | Code | Where | What your UI should do |
|---|---|---|---|
| 401 | UNAUTHORIZED | Any | Check the key your backend sends |
| 403 | ORG_SCOPE_REQUIRED | Any | Use the organization key, and drop X-Tenant-Id |
| 404 | RESOURCE_NOT_FOUND | Any | The call, bank, flag, run or recording is not in your organization, or was deleted. Reload the list it came from |
| 400 | VALIDATION_ERROR | Any | Show each details[].message beside its field |
| 400 | INTERNAL_SERVER_ERROR | Any request with a body | A required field is missing or cannot be read as its type; there are no details. Check the request your form builds |
| 409 | TURN_FLAG_EXISTS | Flag a line | Open details.existingFlagId |
| 422 | LINE_NOT_FLAGGABLE | Flag a line | Show details.reason on the line |
| 429 | BACKLOG_FULL | Flag a line, save candidates with backfill: true | Keep the form, retry later; for candidates, or save with backfill: false |
| 409 | BANK_NAME_EXISTS | Create or rename a bank | Ask for another name |
| 422 | BANK_LIMIT | Bank candidates | Cap the picker at details.limit |
| 422 | CALL_NOT_RUNNABLE | Run estimate and start | Mark details.callId with details.reason |
| 422 | CANDIDATE_REQUIRED | Run estimate and start | Ask for a candidate |
| 422 | NOT_A_BANK_CANDIDATE | Run estimate and start | Reload the bank |
| 409 | RUN_ACTIVE | Start a run, relabel | Link to details.runId, or wait for the run to finish |
| 409 | RUN_CANCELLED | Relabel | The run was cancelled; offer to start a new run |
| 500 | RECORDINGS_NOT_CONFIGURED | Play a line | Disable playback and try later |
Limits#
| Limit | Value |
|---|---|
| Candidates per bank | 8 |
| Control lines per flag | 0, 1 or 2, fewer when the call has fewer other caller lines with audio timing |
| Flags per caller line | 1 |
| Error tags per flag | 8, each 1 to 32 lowercase letters or underscores |
| Expected text, note | 2000 characters each |
| Window for cards and tables | 1 to 90 days, default 30 |
| Page size of flag, turn, run and call lists | 1 to 200, default 50 |
| Eligible calls per page | 1 to 20, default 20, from the last 30 days |
| Calls per full-call run | 1 to 10 |
| Calibration lines per run | 0 to 200, default 20 |
| Active full-call runs per organization | 1 queued or running |
| Review sample per run | Up to 120 lines |
| CSV export | Up to 5000 lines |
| Recording link lifetime | 10 minutes |
Scoring work is shared: each organization has a few jobs running at a time, and a large backlog makes new flags wait. When an organization has more jobs waiting than details.limit of a 429 BACKLOG_FULL (50 today), flags and backfills that would queue more are refused until the queue drains.
Costs#
- Production costs nothing. A line's live attempt is the transcript the call already stored.
- Every candidate replay is speech-to-text audio. A replay transcribes the line plus 0.75 seconds on each side, and 1.5 seconds of trailing silence the provider bills, at that candidate's rate.
- Estimates come before large spends.
POST /transcriber-banks/{id}/candidates/estimateprices a backfill andPOST /transcriber-banks/{id}/full-call-runs/estimateprices a run. A single flag has no estimate; it replays one line, plus its controls, once per candidate. - The judge costs tokens only. Tie-breaks, labels, patterns and verdicts use a language model. Estimates cover speech-to-text only, so a run's actual cost can be a little higher than its estimate.
- Rescoring is free. Editing an expected text, and rescoring a run without relabeling, reuse stored transcripts.
Clean up#
- Remove a flag with
DELETE /turn-flags/{id}. Its controls go with it, and both leave every score. - Delete a bank with
DELETE /transcriber-banks/{id}. It answers204. Its lines are not deleted: they become unassigned and appear underGET /turn-flags?unassigned=true, where you can move them to another bank or remove them. - Runs cannot be deleted. Cancel one that is still going with
POST /full-call-runs/{id}/cancel. Once its bank is deleted, a run no longer appears in any list, so keep run ids you may want to read later.
Next steps#
- Introduction: the ideas behind banks, control lines, scoring and full-call runs.
- Test transcribers in the dashboard: the same flow in the OnCore dashboard, for organization admins.
- Transcriber Tests API: every endpoint and field, generated from the specification.
- Providers: switch an assistant to the transcriber that won.