ScriptHaul API
Log inGet API key

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.

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