Bulk jobs
POST /v1/jobs accepts a channel, playlist, list: key, or a video_ids subset and returns a durable job. Send an Idempotency-Key of 1–128 characters when a create might be retried. The key is scoped to the account: the same key replays the same result and never creates or reserves twice.
To send this job to a different receiver, first configure the account webhook once with PUT /v1/webhooks, then include webhook_url. The per-job value overrides only the endpoint and reuses the account signing secret; without that configured secret, creation fails before mutation with webhook_not_configured.
curl https://api.scripthaul.com/v1/jobs -X POST \
-H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \
-H "Idempotency-Key: weekly-research-2026-09-04" \
-H "Content-Type: application/json" \
-d '{"input":"list:0123456789abcdef","format":"json","language":"en","fallback":true}'
Job creation and enumeration cost zero. ScriptHaul reserves one credit for each cold row, settles exactly one when that row reaches fetched, marks cache hits at zero, and releases every unsettled reservation on terminal failure or cancel. When the available balance is short, the job is paused with pause_reason: "insufficient_credits"; buying credits or the scheduled reconciler resumes it.
When YouTube declines a translation
Translation is opt-in: add "translate": true to the job (keyed MCP create_bulk_job: translate: true). Any other value is a 400 with code: "invalid_translate". The job keeps its choice, and an Idempotency-Key replay answers with the stored one. YouTube is always asked first. For each video whose captions YouTube declines to translate into the job's language, the job delivers that language whenever ScriptHaul can: in most languages, when the account's available balance and the key's daily credit cap cover the price at that moment. The price is 300 credits per started 10,000 characters of transcript (at most 1,500), on top of the delivery credit. It is never part of the job's reservation: each such video pays it when delivered, labelled like any served translation (delivered_language is the job's language and translation is served). A strict job (fallback: false) receives the translation instead of a retryable refusal.
When a video's requested language cannot be delivered this way, nothing extra is charged and the row keeps the outcome it has without the option: the labelled source track, or a retryable RateLimited row in a strict job. A job that opted in never takes a labelled source copy that an earlier refusal left in the cache; it asks YouTube again, while a copy already cached in the requested language stays a free hit. Every later request or job for that video and language reads the translation as an ordinary cache hit at 0 credits. Automatic monitor jobs never translate.
A job created with translate: true also shows "translate": true and translation_credits, the credits charged for its translated videos, beside credits, whose reserved, charged and released counts stay delivery credits. Its video rows, its manifests (a trailing translation_credits column) and its terminal webhook (data.translation_credits) carry the same figure, per video or in total, 0 when nothing was translated. Jobs created without it are unchanged.
Lifecycle and reads
Read GET /v1/jobs/{id} or page GET /v1/jobs/{id}/videos?limit=100&offset=0&status=error. Every row includes status (pending, processing, cached, fetched, an error state, or cancelled), requested_language, delivered_language, fallback (the actual result, or null before delivery), translation (not_requested, served, refused, or unavailable; null before delivery), lifetime attempts, credits_charged, and error_class; a job created with translate: true adds translation_credits. The language field remains a delivered-language alias. GET /v1/jobs/{id}/events is a short-lived SSE progress stream that carries status, counts and credits but no per-video rows and asks clients to reconnect after 10 seconds; confirm final state through the ordinary JSON read.
The JSON or CSV manifest at /v1/jobs/{id}/manifest contains position, video ID, title, published date, status, requested and delivered language, actual fallback and translation facts, the legacy language/track aliases, caption kind, attempts, credits charged, strategy, output filename, SHA-256 checksum, and error fields, then translation_credits for a job created with translate: true. Each successful row names its exact transcripts/YYYY-MM-DD - Title.ext output path; duplicate names receive deterministic (2), (3), and later suffixes. The SHA-256 value is computed over the exact bytes returned by /v1/jobs/{id}/files/{videoId} in the job format. Files and manifests cost zero credits, although each successful transcript-body download counts toward the authenticated key's cache-read limit.
POST /v1/jobs/{id}/retry requeues eligible failures without charging a trigger fee. POST /v1/jobs/{id}/cancel is idempotent, prevents new claims, and releases the unsettled reservation; already delivered rows remain charged.
Server-side archives
After a job is terminal, GET /v1/jobs/{id}/archive queues or returns a ZIP at zero credits. This transcript-body route requires a Bearer key; dashboard cookies and crawlers are refused. With no query parameter, the archive uses the job's one format. To request several, repeat format, send a comma-separated value, or use the formats alias—but do not send both names.
curl --get "https://api.scripthaul.com/v1/jobs/$JOB_ID/archive" \
-H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \
--data-urlencode "formats=clean,srt"
A single-format ZIP uses Source/transcripts/YYYY-MM-DD - Title.ext, plus Source/manifest.json and Source/manifest.csv. A multi-format ZIP uses Source/{format}/transcripts/...; every part carries manifests that identify its files. Multi-format builds split before a ZIP can exceed 80 MiB. When there is more than one part, the endpoint returns a JSON index; fetch part=1, part=2, and later URLs from that response with the same key. The queue consumer deflates one file at a time and uploads each part to R2 in 5 MiB multipart pieces under a 48 MiB working-set budget, so a seven-format job is never buffered in memory.
Archives expire after seven days. The endpoint then queues a fresh generation if the job metadata is still retained. API-origin titles and dates are still subject to the 30-day Data API purge, which invalidates any archive containing them rather than extending that metadata's life.
Every part uses the same manifest columns as the ordinary job manifest. output_file is relative to the ZIP root (transcripts/... for one format and {format}/transcripts/... for multi-format parts), and sha256 covers the exact rendered file bytes. Transient build failures are retried after 60 seconds, 10 minutes, and 60 minutes. A build that exhausts its attempts answers 409 with the standard error envelope (code: "archive_failed", retryable: true, Retry-After for the one-hour cool-down) and ordinary polling does not start another cycle; GET /v1/jobs/{id}/archive?rebuild=1 starts a fresh build immediately, as does a retained job change or the seven-day expiry.
Create a job in four languages
Maintained runnable versions are in the examples directory, including the n8n bulk-job export.
curl https://api.scripthaul.com/v1/jobs -X POST -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: docs-bulk" -d '{"input":"list:0123456789abcdef","format":"json"}'
import json, os, urllib.request
body = json.dumps({"input": "list:0123456789abcdef", "format": "json"}).encode()
request = urllib.request.Request("https://api.scripthaul.com/v1/jobs", data=body, method="POST")
request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}")
request.add_header("Idempotency-Key", "docs-bulk")
request.add_header("Content-Type", "application/json")
print(urllib.request.urlopen(request).read().decode())
const response = await fetch("https://api.scripthaul.com/v1/jobs", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "docs-bulk" },
body: JSON.stringify({ input: "list:0123456789abcdef", format: "json" }),
});
console.log(await response.json());
body := strings.NewReader(`{"input":"list:0123456789abcdef","format":"json"}`)
req, _ := http.NewRequest("POST", "https://api.scripthaul.com/v1/jobs", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY"))
req.Header.Set("Idempotency-Key", "docs-bulk")
req.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer response.Body.Close()
io.Copy(os.Stdout, response.Body)