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