Runs
A run is one transcription: the unit of work, the row you poll, the thing a webhook is about. Every run has one source, of three kinds.
Submitting the three source types
A YouTube URL
curl -sS -X POST https://<host>/v1/runs \
-H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"source":{"type":"youtube","url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"},"name_speakers":true}'
{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","source_type":"youtube","source_url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","status":"queued","lane":null,"name_speakers":true,"segments":null,"media_fingerprint":"dQw4w9WgXcQ","created_at":"2026-03-11T18:04:11Z","…":"…"}
Any YouTube URL shape works — watch, youtu.be, shorts, live, embed, the mobile host, with or
without extra parameters — and so does a bare 11-character video id. A URL that is not YouTube is
422 unprocessable_source with detail.reason: "unrecognised_youtube_url".
An uploaded file, or a recording
Both go through the upload protocol, which normally creates the run for you
(create_run: true). This shape exists for a client that opened an upload first and decided later:
curl -sS -X POST https://<host>/v1/runs \
-H "Authorization: Bearer $SCRIPTRIP_KEY" -H "Content-Type: application/json" \
-d '{"source":{"type":"upload","upload_id":"0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab"}}'
{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","source_type":"upload","source_url":null,"source_upload_id":"0b5f2e63-83a1-4a30-9a35-64d2f0f9a7ab","status":"receiving","…":"…"}
type: "recording" is identical for an upload of kind recording. The run is receiving while the
upload is still open and queued once it is complete.
The options
| Field | Default | What it does |
|---|---|---|
| name_speakers | true | Ask for names. A single-speaker recording skips naming and says so in the step detail |
| requested_language | null | Pin ASR to a language ("en", "pt-BR"). null auto-detects. A pinned language bypasses the cache |
| force | false | true skips the cached lane and transcribes again — see idempotency and caching |
| idempotency_key | null | The header, as a body field, for clients that cannot set one. If both are present and differ, 400 |
Polling
GET /v1/runs/{id} is the polling endpoint. Poll no faster than every 2 seconds, back off to 10
seconds after a minute — or register a destination and do not poll at all.
curl -sS "https://<host>/v1/runs/0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b" -H "Authorization: Bearer $SCRIPTRIP_KEY"
{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","status":"running","lane":"modal","engine":"whisperx large-v2","current_step":"diarize","step_log":[{"step":"fetch","started_at":"2026-03-11T18:04:22Z","ended_at":"2026-03-11T18:05:09Z","ok":true,"detail":"45.2 MB in 47 s"},{"step":"normalize","started_at":"2026-03-11T18:05:09Z","ended_at":"2026-03-11T18:05:18Z","ok":true,"detail":"2711.5 s · 16 kHz mono"},{"step":"transcribe","started_at":"2026-03-11T18:05:18Z","ended_at":"2026-03-11T18:07:44Z","ok":true,"detail":"412 segments · language en"},{"step":"align","started_at":"2026-03-11T18:07:44Z","ended_at":"2026-03-11T18:08:31Z","ok":true,"detail":"word-level timestamps for 412 segments"},{"step":"diarize","started_at":"2026-03-11T18:08:31Z","ended_at":null,"ok":null,"detail":null}],"segments":null,"expected_steps":["fetch","normalize","transcribe","align","diarize","name_speakers","finalize"],"…":"…"}
Draw progress from step_log, never from a script. expected_steps is null until a lane has
taken the run — before that there is nothing honest to draw. A step with ended_at: null is open;
if the run is terminal and a step is still open, that is where it stalled.
While a run waits for a machine with lane: null, seconds_until_modal counts down to a GPU
starting and live_workers says how many of the operator's own machines could take it first. Once
a GPU has been asked for, lane reads modal_pending and both fields are null — the countdown
has ended and a container is on its way; it becomes modal when the container claims the run,
usually within ten seconds. A submission may already answer modal_pending when no machine of
the operator's own is alive.
current_step is the open step, and null in the seconds between one step closing and the next
opening; step_log is the record to draw from, and a run whose last entry is closed is between
steps, not stuck.
The payload rule
segments and speaker_labels are null until status is done. null means not yet,
never there is none — status is what says which. A 40-minute transcript is about a megabyte;
shipping it on every 2-second poll would make the progress display cost more than the transcription.
GET /v1/runs (the list) never carries segments at all, in any status.
When status is done, the same endpoint carries the artifact:
curl -sS "https://<host>/v1/runs/0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b" -H "Authorization: Bearer $SCRIPTRIP_KEY" | jq '{status, segment_count, speaker_labels, first: .segments[0], transcript_url}'
{"status":"done","segment_count":412,"speaker_labels":{"SPEAKER_00":{"name":"Matt","role":"host"},"SPEAKER_01":{"name":"Amy","role":"guest"}},"first":{"start":0.48,"end":4.02,"text":"Right, so this is day nine.","speaker":"SPEAKER_00"},"transcript_url":"https://<host>/v1/runs/0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b/transcript"}
A segment is {start, end, text, speaker} and nothing more. speaker is present on every
segment — null where diarization had nothing to say — and speaker ids are SPEAKER_00,
SPEAKER_01, … in order of first appearance. speaker_labels maps them to {name, role} when
naming ran; either half may be null on its own — a voice can be recognised as the narrator
without ever being named — and the whole field is null when naming did not run at all.
The six formats
GET /v1/runs/{id}/transcript?format=… returns one rendering as text. They are six renderings of
one segments array, produced by one formatter that the web app and the mobile apps also use, so a
transcript copied from the app and one fetched here are byte-identical.
| format | Content-Type | Shape |
|---|---|---|
| prose | text/plain | Flowing paragraphs, no labels, no timestamps; a speaker change starts a paragraph |
| speakers (default) | text/plain | Matt: where a name exists, Speaker 1 (host): where only a role does, Speaker 1: where neither — never the raw SPEAKER_00 |
| timestamped | text/plain | [00:12:44] Matt: So we left at first light. — turn-level, HH:MM:SS |
| srt | application/x-subrip | SubRip; segment-level; HH:MM:SS,mmm |
| vtt | text/vtt | WebVTT |
| json | application/json | {segments, speaker_labels, language, engine} — the raw artifact |
curl -sS "https://<host>/v1/runs/0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b/transcript?format=timestamped" -H "Authorization: Bearer $SCRIPTRIP_KEY"
[00:00:00] Matt: Right, so this is day nine. Day nine and we still haven't seen land.
[00:00:08] Amy: I keep telling him that's the point.
Add &download=1 to receive it as an attachment named after the title. A run that is not done
is 409 conflict with detail.status saying what it actually is.
Stopping and deleting
POST /v1/runs/{id}/cancel stops a run in flight. It is cooperative: the machine finishes the step
it is in the middle of and stops at the boundary. The response carries the run, now canceled, and
a sentence saying what to expect. Cancelling a run that is already finished is 409 conflict with
detail.status.
curl -sS -X POST "https://<host>/v1/runs/0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b/cancel" -H "Authorization: Bearer $SCRIPTRIP_KEY"
{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","status":"canceled","error_class":"canceled","error":"Stopped by a person.","message":"Stopped. The GPU finishes what it is in the middle of and shuts down; you are not charged for the rest.","…":"…"}
DELETE /v1/runs/{id} deletes the run and purges its media immediately — rows and objects both.
It answers 200 with a receipt, never 204; a second delete is 404.
curl -sS -X DELETE "https://<host>/v1/runs/0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b" -H "Authorization: Bearer $SCRIPTRIP_KEY"
{"id":"0195c8e4-8d02-7c19-b3e7-5a1f9d20c68b","deleted":true,"media_objects_purged":1,"message":"The run and 1 media object were deleted."}
When a run fails
A run that fails is not an API error: the submission answered 201, the work was accepted, and
the failure is status: "failed" with an error_class on the resource. Branch on error_class,
never on the error sentence. The one worth special handling is premiere — a scheduled video that
has not aired — which carries retry_after: submit a new run at that time; a failed run is never
mutated. The errors page has the full table.