Skip to content

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 with 403 ORG_SCOPE_REQUIRED, and so is an organization key sent with the X-Tenant-Id of one of its tenants (a tenant of another organization gets 403 FORBIDDEN first). 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-Of header. Send the signed-in person's name or your own user id; it comes back as the record's createdBy, which is null when 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 the id of a call from GET /calls, not its cid or callSid. GET /calls is tenant-scoped, so that one request takes X-Tenant-Id like 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:

oncore.ts
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:

csv.ts
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#

ScreenWhat it showsEndpoints
Call transcript, flagging modeWhich lines can be flagged, a player per line, the flag formGET /turn-flags/calls/{callId}/lines, GET /turn-flags/calls/{callId}/recording-url, GET /turn-flags/tags, POST /turn-flags
ResultsOne card per bank: accuracy per transcriber, the leader, the thresholdGET /transcriber-banks, POST /transcriber-banks
Bank detailThe turn-by-turn table, the candidate editorGET /transcriber-banks/{id}, GET /transcriber-banks/{id}/turns, PUT /transcriber-banks/{id}/candidates
Flagged turnsEvery flag, filterable, with edit and removeGET /turn-flags, PATCH /turn-flags/{id}, DELETE /turn-flags/{id}
Full-call runThe 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.

bash
curl https://core-api.heysadie.ai/turn-flags/calls/0195f1e2-1111-7000-8000-000000000000/lines \
  -H "Authorization: ApiKey ORG_API_KEY"
200 response
{
  "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" }
}
FieldMeaning
data[].messageIndexThe line's position in the call's messages. You send it back when you flag
data[].roleuser is the caller, bot is the assistant
data[].textWhat the live transcriber wrote
data[].audioWhere the line sits in the recording, in seconds, or null
data[].flaggable / notFlaggableReasonWhether the line can be flagged, and why not
data[].flagThe turn already on the line: { id, kind }, where kind is flagged or control, else null
recordingnull when the call has no recording. channels is stereo (assistant left, caller right) or mono
liveTranscriberThe 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:

notFlaggableReasonSuggested copy
not_caller_lineOnly the caller's lines can be flagged
no_recordingThis call has no recording
transcriber_unknownWe can't tell which transcriber this call used
no_audio_boundsThis 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.

bash
curl https://core-api.heysadie.ai/turn-flags/calls/0195f1e2-1111-7000-8000-000000000000/recording-url \
  -H "Authorization: ApiKey ORG_API_KEY"
data
{
  "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:

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

bash
curl "https://core-api.heysadie.ai/transcribers?limit=500" \
  -H "Authorization: ApiKey ORG_API_KEY"
data
{
  "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:

FieldRequiredMeaning
nameYes1 to 120 characters, unique in your organization ignoring case
languageLabelYesA display label, 1 to 60 characters, such as en-US or en + fr
candidateIdsYesTranscriber ids, distinct, at most 8, in the order you want them shown. Can be empty and filled later
thresholdNoThe accuracy, from 0 to 1, a candidate must reach to clear the bank. Default 0.6
controlLinesPerFlagNo0, 1 or 2 control lines sampled with each flag. Default 1
bash
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"
    ]
  }'
201 data
{
  "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.

ErrorWhenWhat your UI should do
409 BANK_NAME_EXISTSAnother bank has this nameKeep the dialog open and ask for another name
422 BANK_LIMITMore than details.limit candidatesStop ticking at 8 so this never reaches the API
400 VALIDATION_ERRORA field broke a rule, or a candidate id is repeatedShow each message beside its field
404 RESOURCE_NOT_FOUNDA candidate id is not an active transcriber in the catalogue; details.transcriberIds lists themReload 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:

FieldRequiredMeaning
callIdYesThe call's id
messageIndexYesThe line's messageIndex from the lines response
expectedTextYesWhat the caller said, word for word. 1 to 2000 characters
errorTagsYesThe tags, possibly empty
commentNoUp to 2000 characters. Blank is stored as null
bankIdNoThe bank the flag joins. Leave it out, or send null, to keep the flag in no bank
bash
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"
  }'
201 data
{
  "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:

FieldMeaning
kindflagged for a person's flag, control for a sampled control line, full_call for a line of a full-call run
heardTextWhat the live transcriber wrote, frozen when the turn was made
expectedTextWhat was said. A control's is its heard text
bankIdThe bank, or null while the turn is in no bank
evaluationpending, scored or failed
evaluationProgressThe job scoring the turn, or null. See the next section
liveTranscriberThe transcriber the call ran on. Its stored text is the turn's production attempt
controlForTurnIdOn a control, the flag it was sampled for
tieBreakWhen 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.

ErrorWhenWhat your UI should do
409 TURN_FLAG_EXISTSThe line already has a flag; details.existingFlagId names it, or is null if it was just removedOpen that flag for editing, or reload the lines
422 LINE_NOT_FLAGGABLEdetails.reason is one of the reasons in the table aboveShow the reason on the line and close the form
404 RESOURCE_NOT_FOUNDThe call is not in your organization, or bankId is not a bank of itReload the banks; the bank may have been deleted
429 BACKLOG_FULLYour organization has more than details.limit jobs waiting. Nothing was written, and there is no Retry-AfterKeep 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_ERRORA field broke a rule; details lists the fieldsShow 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:

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

evaluationProgress
{ "stage": "scoring", "done": 12, "total": 40 }
stageMeaningSuggested copy
waitingThe job is queued and no worker has started it"Waiting to score"
scoringA 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:

evaluationHow it gets there
pendingFlagged 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
scoredEvery 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
failedIts 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.

bash
curl "https://core-api.heysadie.ai/transcriber-banks?windowDays=30" \
  -H "Authorization: ApiKey ORG_API_KEY"
data.data[0]
{
  "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 scores row. Candidates come first, in the bank's order, then one production row 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's threshold as a tick on every bar.
  • accuracy is matched / scored, or null while nothing is scored. Show "No scores yet" rather than 0%. errors counts attempts that failed to transcribe; they are left out of scored. meanWer is the average word error rate over scored attempts.
  • The badge. When leader.clearsThreshold is true, "Deepgram Nova 3 clears 60%". When leader is present but does not clear it, or is null, "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.

bash
curl "https://core-api.heysadie.ai/transcriber-banks/0195f1e2-5555-7000-8000-000000000000/turns?windowDays=30&limit=50" \
  -H "Authorization: ApiKey ORG_API_KEY"
data
{
  "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 stateHow to tellWhat to show
Not run yetNo 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"
Failederror is not null. Its text, match and wer are nullThe 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
Matchedmatch: trueA tick. notes can hold case and punctuation: show a small note that the raw text differed only that way
Missedmatch: falseThe 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:

FieldEffect
expectedTextRescores the line's stored attempts at once, at no transcription cost
errorTagsReplaces the tags
commentReplaces the note. null or blank clears it
bankIdMoves 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
bash
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:

bash
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"] }'
data
{ "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:

bash
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 }'
data
{
  "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:

  1. The candidate. Default to leader.transcriberId from the bank's card, and let the person pick any other candidate of the bank. Send candidateTranscriberId every 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.
  2. The calls. Start with the flagged line's callId. Let the person add more, up to 10 in total, and remove any of them.
  3. The estimate, refreshed as the selection changes.
  4. 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.

bash
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"
data.data[0]
{
  "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:

FieldRequiredMeaning
callIdsYes1 to 10 distinct call ids, replayed in this order
candidateTranscriberIdNoA candidate of the bank. Defaults to the bank's leader over the last 30 days
calibrationTurnsNoLines on which the live transcriber is replayed too, to measure how well a replay reproduces the live text. 0 to 200, default 20
bash
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"] }'
data
{ "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.

bash
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"] }'
201 data
{
  "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:

ErrorWhenWhat your UI should do
422 CALL_NOT_RUNNABLEA 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_unknownMark that call in the list with the reason and let the person remove it
422 CANDIDATE_REQUIREDNo candidate was sent and the bank has no leader yetAsk the person to pick a candidate
422 NOT_A_BANK_CANDIDATEdetails.candidateTranscriberId is not a candidate of the bank, or has left the catalogueReload the bank and reset the picker
404 RESOURCE_NOT_FOUNDA call or the bank is not in your organizationRemove the call, or reload the banks
409 RUN_ACTIVEAnother run of your organization is queued or running; details.runId names it. A run that is only analyzing does not blockLink to that run and say one run goes at a time. The estimate never returns this
400 VALIDATION_ERRORNo calls, more than 10, a repeated call id, or a bad fieldKeep 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.

bash
curl -i https://core-api.heysadie.ai/full-call-runs/0195f1e2-cccc-7000-8000-000000000000 \
  -H "Authorization: ApiKey ORG_API_KEY"
run.statusMeaningWhat to show
queuedWaiting for a worker"Waiting to start"
runningThe candidate is replaying the caller linesA bar from progress.done out of progress.total
analyzingThe judge is labeling the lines that differ"Analyzing differences"
completedFinishedThe results below
cancelledSomeone cancelled itIts partial results, marked as cancelled
errorEvery transcription job of the run ended without results (for example, timed out), or the analysis failedrun.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.

data
{
  "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 null until 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) and totals.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: identical against differing, then the labeled counts. A differing line with a flag is labeled from its expected text; the judge labels the rest. totals.reviewed counts people's reviews on differing lines, any line of the run, not only the sample. For "N of M checked", use the review queue's reviewed and sampled, which both count only the sample, so N never exceeds M. A review outranks the judge's label in fixed and introduced only 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 than callerTurns. Show audioPathAgreement as the share of calibration replays that reproduced the live text. It is null until 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, the example pair, turns (how many lines) and note. turnIds are the run's lines in the pattern; match them against the turn.id of rows from the line-by-line list, which has no filter by id.
  • Cost: cost.estimatedUsd, and cost.actualUsd once jobs are priced (it is null before). 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:

data.data[0]
{
  "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:

LineCells
Has a flag (turn.expectedText set)As in the bank's table: match, wer and notes against the expected text
No flagmatch 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 columnThe 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
{
  "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:

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

StatusCodeWhereWhat your UI should do
401UNAUTHORIZEDAnyCheck the key your backend sends
403ORG_SCOPE_REQUIREDAnyUse the organization key, and drop X-Tenant-Id
404RESOURCE_NOT_FOUNDAnyThe call, bank, flag, run or recording is not in your organization, or was deleted. Reload the list it came from
400VALIDATION_ERRORAnyShow each details[].message beside its field
400INTERNAL_SERVER_ERRORAny request with a bodyA required field is missing or cannot be read as its type; there are no details. Check the request your form builds
409TURN_FLAG_EXISTSFlag a lineOpen details.existingFlagId
422LINE_NOT_FLAGGABLEFlag a lineShow details.reason on the line
429BACKLOG_FULLFlag a line, save candidates with backfill: trueKeep the form, retry later; for candidates, or save with backfill: false
409BANK_NAME_EXISTSCreate or rename a bankAsk for another name
422BANK_LIMITBank candidatesCap the picker at details.limit
422CALL_NOT_RUNNABLERun estimate and startMark details.callId with details.reason
422CANDIDATE_REQUIREDRun estimate and startAsk for a candidate
422NOT_A_BANK_CANDIDATERun estimate and startReload the bank
409RUN_ACTIVEStart a run, relabelLink to details.runId, or wait for the run to finish
409RUN_CANCELLEDRelabelThe run was cancelled; offer to start a new run
500RECORDINGS_NOT_CONFIGUREDPlay a lineDisable playback and try later

Limits#

LimitValue
Candidates per bank8
Control lines per flag0, 1 or 2, fewer when the call has fewer other caller lines with audio timing
Flags per caller line1
Error tags per flag8, each 1 to 32 lowercase letters or underscores
Expected text, note2000 characters each
Window for cards and tables1 to 90 days, default 30
Page size of flag, turn, run and call lists1 to 200, default 50
Eligible calls per page1 to 20, default 20, from the last 30 days
Calls per full-call run1 to 10
Calibration lines per run0 to 200, default 20
Active full-call runs per organization1 queued or running
Review sample per runUp to 120 lines
CSV exportUp to 5000 lines
Recording link lifetime10 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/estimate prices a backfill and POST /transcriber-banks/{id}/full-call-runs/estimate prices 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 answers 204. Its lines are not deleted: they become unassigned and appear under GET /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#