# Quickstart Get a key with an email address, then make one authenticated request. No password or card is required for the free tier. ## 1. Request a code ```bash curl https://api.scripthaul.com/v1/auth/code \ -H "Content-Type: application/json" \ -d '{"email":"you@example.com"}' ``` ## 2. Verify and save the key ```bash curl https://api.scripthaul.com/v1/auth/verify \ -H "Content-Type: application/json" \ -d '{"email":"you@example.com","code":"123456","accept_terms":true}' ``` The response shows the `sh_live_…` key once. Store it in a secret manager. Never put it in a URL, log, browser bundle, or query string. ```bash export SCRIPTHAUL_API_KEY="sh_live_replace_me" ``` ## 3. Fetch a transcript ```bash curl --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" \ --data-urlencode "format=clean" ``` Inspect `X-Cache`, `X-Credits-Charged`, `X-Credits-Balance`, and `X-Request-Id`. A cache hit costs 0. A successful cold delivery costs 1. Failures cost 0. ## 4. Download SRT ```bash curl --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" \ --data-urlencode "format=srt" \ --data-urlencode "raw=1" \ -o jNQXAC9IVRw.srt ``` Cold calls may return `202` after 25 seconds. Follow the returned `poll_url` with the same Bearer key; polling itself does not add another delivery charge. ## Thin clients The MIT-licensed JavaScript and Python clients are published to npm and PyPI once the live verification run passes; until then use curl or copy the [examples](/docs/examples). Every response carries `meta` (cache, credits charged, balance, rate limit, request id), and `wait()` stops on a paused job instead of polling through it. Both clients expose the same small surface: `transcript()`, `videos()`, and `jobs.create()` returning a job with `wait()`. They bound retries for 429/502/503 responses and 202 polling, raise typed errors that mirror `errorClass`, and keep `request_id` on exceptions. ```javascript import ScriptHaul from "scripthaul"; const client = new ScriptHaul({ apiKey: process.env.SCRIPTHAUL_API_KEY }); const job = await client.jobs.create({ input: "https://www.youtube.com/@example" }); await job.wait(); ``` ```python from scripthaul import ScriptHaul client = ScriptHaul() job = client.jobs.create(input="https://www.youtube.com/@example").wait() ``` ## Next - [Understand requested and delivered languages](/docs/transcripts) - [List channel and playlist videos](/docs/listing) - [See every credit and capacity limit](/docs/credits-limits) - [Handle typed errors](/docs/errors) ## Quickstart in four languages All examples read the key from `SCRIPTHAUL_API_KEY`. Maintained runnable versions are in the [examples directory](/docs/examples). ```bash curl --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" --data-urlencode "format=clean" ``` ```python import os, urllib.request request = urllib.request.Request("https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&format=clean") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&format=clean", { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` }, }); console.log(await response.json()); ``` ```go req, _ := http.NewRequest("GET", "https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&format=clean", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Authentication and keys ScriptHaul accounts are verified email addresses. Codes last 10 minutes; five wrong attempts lock that code for 15 minutes. Addresses are normalized before uniqueness checks. ## Bearer only Send keys only in the `Authorization` header. ```http Authorization: Bearer sh_live_… ``` Keys in `api_key`, `key`, `token`, or similar query parameters are rejected. ScriptHaul stores only a peppered SHA-256 key hash and a 12-character display prefix. ## List and create keys ```bash curl https://api.scripthaul.com/v1/keys \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" curl https://api.scripthaul.com/v1/keys \ -X POST \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Production","daily_credit_cap":250}' ``` Free accounts may hold 2 live keys; paid accounts may hold 5. `daily_credit_cap` is an optional customer-controlled kill switch. ## Revoke or rotate ```bash curl https://api.scripthaul.com/v1/keys/KEY_ID \ -X DELETE \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"grace_period_hours":0}' curl https://api.scripthaul.com/v1/keys/KEY_ID/rotate \ -X POST \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` Rotation creates a new key and gives the old key a 24-hour grace period. Immediate revocation uses `0`; scheduled revocation uses `24`. Authentication lookups are memoized for up to 30 seconds, so an immediately revoked key may continue to authenticate for at most that documented cache window. ## Dashboard sessions The dashboard uses an `HttpOnly`, `Secure`, `SameSite=Strict` cookie. It is accepted only on dashboard-safe account, key, usage, job-read, and checkout routes. It never authenticates transcript delivery or MCP calls. Cookie-authenticated mutations require `X-ScriptHaul-Dashboard: 1`. ## List keys in four languages These requests keep the credential in the header. Maintained runnable versions are in the [examples directory](/docs/examples). ```bash curl https://api.scripthaul.com/v1/keys \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" ``` ```python import os, urllib.request request = urllib.request.Request("https://api.scripthaul.com/v1/keys") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/keys", { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` }, }); console.log(await response.json()); ``` ```go req, _ := http.NewRequest("GET", "https://api.scripthaul.com/v1/keys", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Transcripts and languages `GET /v1/transcript` is cache-first. Supply `video_id`, `format`, optional `language`, optional `fallback`, and optional `raw=1`. ```bash curl --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" \ --data-urlencode "language=es" \ --data-urlencode "fallback=true" \ --data-urlencode "format=json" ``` ## Language contract | Situation | requested | delivered | fallback | translation | | --- | --- | --- | --- | --- | | Native requested track | `en` | `en` | `false` | `not_requested` | | Translation served | `es` | `es` | `false` | `served` | | Translation refused, fallback allowed | `es` | source language | `true` | `refused` | | Translation refused, strict mode | `es` | none | none | retryable `translation_refused` error, 0 credits | | No translation exists, fallback allowed | `es` | source language | `true` | `unavailable` | | No translation exists, strict mode | `es` | none | none | `language_unavailable` error with `available_languages`, 0 credits | `kind` is `manual` or `asr`. Set `fallback=false` for strict mode. A labelled fallback delivery costs 1 when cold because a transcript was delivered; a strict refusal costs 0. `refused` is the throttle case and can succeed on a later attempt; `unavailable` is a fact about the video. Bulk job rows report `null` before delivery and never `unavailable`. ```json { "language": { "requested": "es", "delivered": "en", "kind": "manual", "fallback": true, "translation": "refused" } } ``` ## Seven formats Samples use a synthetic timing fixture based on the public-domain U.S. Constitution, never a customer transcript or cached YouTube transcript. | format | response field or raw type | sample | | --- | --- | --- | | `json` | `cues` / JSON | `[{"start":0,"duration":2.4,"text":"We the People…"}]` | | `clean` | `text` / plain text | `We the People of the United States…` | | `timestamped` | `text` / plain text | `[00:00] We the People…` | | `both` | `text` / plain text | clean text plus timestamped section | | `srt` | `text` / SubRip | `1` then `00:00:00,000 --> 00:00:02,400` | | `vtt` | `text` / WebVTT | `WEBVTT` then `00:00.000 --> 00:02.400` | | `md` | `text` / Markdown | heading, metadata, and timestamped cues | Use `raw=1` to receive the rendered file with an attachment filename instead of the JSON wrapper. ## Polling long cold calls A cold request waits for up to 25 seconds. If work continues, it returns `202` with a `request_id` and relative `poll_url`. Repeat that URL with the same Bearer key. Settlement references make retries and polling idempotent. ## Language request in four languages Each sample requests Spanish, allows a labelled fallback, and asks for JSON. Maintained runnable versions use only standard libraries and live in the [examples directory](/docs/examples). ```bash curl --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" --data-urlencode "language=es" \ --data-urlencode "fallback=true" --data-urlencode "format=json" ``` ```python import os, urllib.request url = "https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&language=es&fallback=true&format=json" request = urllib.request.Request(url, headers={"Authorization": f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}"}) print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const url = new URL("https://api.scripthaul.com/v1/transcript"); url.search = new URLSearchParams({ video_id: "jNQXAC9IVRw", language: "es", fallback: "true", format: "json" }); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` } }); console.log(await response.json()); ``` ```go url := "https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&language=es&fallback=true&format=json" req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Video facts `GET /v1/video` returns facts ScriptHaul already knows: video metadata, cached languages, and known caption tracks. It costs 0 and does not trigger a transcript fetch or a YouTube Data API request. ```bash curl --get https://api.scripthaul.com/v1/video \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" ``` Unknown fields are `null`, not guesses. `video.status` is `unknown` until ScriptHaul has attempted the video; `fetched`, `no_captions`, `unavailable`, or `error` afterwards, with `error_class` for the last two. An empty `cached_languages` list means no cached track is known; it does not prove the video has no captions. `caption_tracks` lists the tracks seen on the last fetch and `selected` the one that was delivered. ```json { "ok": true, "video": { "id": "jNQXAC9IVRw", "title": "Me at the zoo", "channel_id": "UC4QobU6STFB0P71PMvOGN5A", "channel_title": "jawed", "published_at": null, "duration": 19, "status": "fetched", "error_class": null, "fetched_at": "2026-09-04 12:00:00" }, "cached_languages": [ {"language_code": "en", "kind": "manual", "cached_at": "2026-09-04T12:00:00.000Z"} ], "caption_tracks": [ {"language_code": "en", "kind": "manual", "name": "English"} ], "selected": {"language_code": "en", "kind": "manual"} } ``` Cold single-video transcript delivery gets title, channel, and length from the relay's existing player response. It does not spend a Data API unit. ## Read facts in four languages This route is read-only and costs zero credits. Maintained runnable variants are in the [examples directory](/docs/examples). ```bash curl --get https://api.scripthaul.com/v1/video \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" ``` ```python import os, urllib.request request = urllib.request.Request("https://api.scripthaul.com/v1/video?video_id=jNQXAC9IVRw") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/video?video_id=jNQXAC9IVRw", { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` }, }); console.log(await response.json()); ``` ```go req, _ := http.NewRequest("GET", "https://api.scripthaul.com/v1/video?video_id=jNQXAC9IVRw", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Listing videos `GET /v1/videos` accepts a YouTube channel or playlist URL in `url`. Channel listings accept `contents=all`, `videos`, or `shorts`. Each response includes at most 500 rows and an offset for continuation. `offset` must be 0 or a multiple of 500 and no larger than 20,000; pass the `next_offset` from the previous page. Other values return `400 InvalidInput` with `code: invalid_offset`. ```bash curl --get https://api.scripthaul.com/v1/videos \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "url=https://www.youtube.com/@YouTube" \ --data-urlencode "contents=videos" \ --data-urlencode "offset=0" ``` Each row labels `cached` and `is_short`. `counts.cold` tells you how many listed videos would need a cold transcript fetch before you choose work. Warm shared snapshots spend no Data API units and do not consume the cold-enumeration allowance. A cold enumeration counts once per input; individual upstream calls are guarded by the global Data API unit cap. | Limit | Free | Paid | | --- | ---: | ---: | | Rows per response | 500 | 500 | | Cold enumerations per key/day | 25 | 25 | Enumeration is a capacity feature, not a paid data product. ScriptHaul deliberately has no search endpoint and never calls YouTube `search.list`. ## List a channel in four languages All variants request the first page of regular uploads. Maintained runnable versions are in the [examples directory](/docs/examples). ```bash curl --get https://api.scripthaul.com/v1/videos \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "url=https://www.youtube.com/@YouTube" \ --data-urlencode "contents=videos" --data-urlencode "offset=0" ``` ```python import os, urllib.parse, urllib.request query = urllib.parse.urlencode({"url": "https://www.youtube.com/@YouTube", "contents": "videos", "offset": 0}) request = urllib.request.Request(f"https://api.scripthaul.com/v1/videos?{query}") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const url = new URL("https://api.scripthaul.com/v1/videos"); url.search = new URLSearchParams({ url: "https://www.youtube.com/@YouTube", contents: "videos", offset: "0" }); const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` } }); console.log(await response.json()); ``` ```go url := "https://api.scripthaul.com/v1/videos?url="+url.QueryEscape("https://www.youtube.com/@YouTube")+"&contents=videos&offset=0" req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Pasted lists and subsets `POST /v1/videos/resolve` extracts YouTube video links and bare IDs from text, deduplicates them, validates them in bounded batches, and returns `found`, `notFound`, and a stable `list:` key. ```bash curl https://api.scripthaul.com/v1/videos/resolve \ -X POST \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"Research queue:\nhttps://youtu.be/jNQXAC9IVRw\ninvalid-id"}' ``` Use the returned list key with `GET /v1/videos?url=list:…`. A list may contain up to 500 unique videos. Values that are malformed, unavailable to the validation call, or absent are reported in `notFound`; they are not silently turned into work. Subsets are validated against the exact inventory snapshot from which they were selected. A client cannot submit arbitrary IDs under another channel or playlist snapshot. ## Resolve a list in four languages The fixture combines one public video ID with an invalid value. Maintained runnable versions are in the [examples directory](/docs/examples). ```bash curl https://api.scripthaul.com/v1/videos/resolve -X POST \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" -H "Content-Type: application/json" \ -d '{"text":"Research queue:\nhttps://youtu.be/jNQXAC9IVRw\ninvalid-id"}' ``` ```python import json, os, urllib.request body = json.dumps({"text": "Research queue:\nhttps://youtu.be/jNQXAC9IVRw\ninvalid-id"}).encode() request = urllib.request.Request("https://api.scripthaul.com/v1/videos/resolve", data=body, method="POST") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") request.add_header("Content-Type", "application/json") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/videos/resolve", { method: "POST", headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ text: "Research queue:\nhttps://youtu.be/jNQXAC9IVRw\ninvalid-id" }), }); console.log(await response.json()); ``` ```go body := strings.NewReader(`{"text":"Research queue:\nhttps://youtu.be/jNQXAC9IVRw\ninvalid-id"}`) req, _ := http.NewRequest("POST", "https://api.scripthaul.com/v1/videos/resolve", body) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) 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) ``` --- # 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`. ```bash 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. ```bash 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](/docs/examples), including the n8n bulk-job export. ```bash 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"}' ``` ```python 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()) ``` ```javascript 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()); ``` ```go 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) ``` --- # Credits and limits One credit means one successfully delivered transcript that was not already cached. | Event | Credits | | --- | ---: | | Cache hit | 0 | | Successful cold transcript, including a labelled fallback | 1 | | No captions, unavailable video, strict refusal, 429, 5xx, or budget pause | 0 | | Listing, facts, account, usage, status, files, manifests, archives, or webhooks | 0 | Free accounts receive 100 credits each UTC month. The allowance resets and does not accumulate. Purchased credits are a separate bucket, never expire, and are spent only after free credits. ## Per-key limits | Limit | Free key | Paid key | | --- | ---: | ---: | | Requests/minute, approximate | 60 | 300 | | Concurrent synchronous cold fetches | 2 | 10 | | Cold fetches/day | 100 | 20,000 | | Cache reads/day | 2,000 | 100,000 | | Cold enumerations/day | 25 | 25 | | Live keys | 2 | 5 | | Active jobs | 1 × 100 videos | 5 × 500 videos | New accounts are limited to 25 cold fetches per UTC day for their first two days. Global guards begin at 50,000 API cold fetches/day, a separate 5,000/day free-tier pool, and 6,000 API Data API units/day. Every API response includes `X-RateLimit-Limit` where a key rate applies and `X-Request-Id`. Authenticated responses include `X-Credits-Charged` and `X-Credits-Balance`. A `429` includes `Retry-After`; exact rate remaining/reset headers are not fabricated. ```bash curl -i --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" ``` ## Automatic outage credit After each UTC day, ScriptHaul checks its own ten-minute serving-strategy observations. A day qualifies only after at least four recorded hours with no healthy strategy; missed observations do not manufacture outage time. Each account then receives `floor(that day's successful delivery debits × 10%)` as purchased, non-expiring credits. Failures already cost zero and are not part of the debit total. The ledger reference is unique per account and UTC day, so retries cannot credit twice. Frozen accounts receive the credit but remain frozen; closed accounts are recorded as skipped. ## Inspect usage and balance in four languages `GET /v1/usage` is the zero-credit way to inspect daily counters over a UTC date range. Its JSON body contains `scope`, `range`, and `rows`; `X-Credits-Balance` carries the current total. Use `GET /v1/account` when you need the separate free, purchased, and reserved buckets. Maintained runnable variants are in the [examples directory](/docs/examples). ```bash curl https://api.scripthaul.com/v1/usage \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" ``` ```python import os, urllib.request request = urllib.request.Request("https://api.scripthaul.com/v1/usage") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/usage", { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` }, }); console.log(await response.json()); ``` ```go req, _ := http.NewRequest("GET", "https://api.scripthaul.com/v1/usage", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Errors Errors are stable JSON, not prose-only HTTP bodies. ```json { "ok": false, "errorClass": "QuotaExceeded", "code": "cold_fetch_cap", "message": "This key reached its daily cold-fetch cap.", "retryable": false, "docs_url": "https://api.scripthaul.com/docs/errors#quota_exceeded", "request_id": "example-request-id", "retry_after": 86400 } ``` Keep `request_id` in logs and support reports; the same value is in the `X-Request-Id` header of every response. Respect `Retry-After`; retry only errors marked retryable and pending `202` operations. A 402 includes `required`, `available`, `shortfall`, and `buy_url`. A 405 lists the supported methods in `allow` and the `Allow` header. `docs_url` links to the `errorClass` section on this page, where the `code` values that class carries are listed. ## Error reference | errorClass | HTTP | Retryable | | --- | ---: | --- | | [IpBlocked](#ip_blocked) | 502 | yes | | [PoTokenRequired](#po_token_required) | 502 | no | | [RateLimited](#rate_limited) | 502 | yes | | [Unavailable](#unavailable) | 410 | no | | [NoCaptions](#no_captions) | 404 | no | | [UpstreamError](#upstream_error) | 502 | yes | | [RelayUnavailable](#relay_unavailable) | 502 | yes | | [BudgetExhausted](#budget_exhausted) | 503 | no | | [InvalidInput](#invalid_input) | 400 | no | | [QuotaExceeded](#quota_exceeded) | 429 | no | | [TurnstileFailed](#turnstile_failed) | 403 | no | | [Forbidden](#forbidden) | 403 | no | | [InsufficientCredits](#insufficient_credits) | 402 | no | | [AccountFrozen](#account_frozen) | 403 | no | | [ConfigurationError](#configuration_error) | 503 | no | `docs_url` on every error links to its errorClass section below; the `code` field is listed there. A response may also carry a status the class does not default to (for example a 404 `not_found` or 405 `method_not_allowed` under `InvalidInput`). ### IpBlocked {#ip_blocked} HTTP 502 · retryable. YouTube blocked the fetch route used for this cold request. The relay rotates routes on its own; retry with backoff. Cache reads are unaffected. Codes: none beyond the class default. ### PoTokenRequired {#po_token_required} HTTP 502 · not retryable. YouTube changed what a fetch route requires. Nothing on your side can fix it; retry later and watch the status page. Codes: none beyond the class default. ### RateLimited {#rate_limited} HTTP 502 · retryable. YouTube asked for a slower cadence, or refused an on-the-fly caption translation (`translation_refused`). Retry with backoff. A strict-mode refusal costs 0. Codes: `translation_refused`. ### Unavailable {#unavailable} HTTP 410 · not retryable. The video is private, deleted, members-only, or otherwise unavailable. The verdict is durable per video and costs 0. Codes: `transcript_not_ready`. ### NoCaptions {#no_captions} HTTP 404 · not retryable. No public caption track exists, or the requested language cannot be delivered in strict mode (`language_unavailable`, with `available_languages`). Costs 0. Codes: `language_unavailable`, `transcript_not_ready`. ### UpstreamError {#upstream_error} HTTP 502 · retryable. An upstream system (YouTube, Stripe, or storage) answered in an unexpected way. Retry with backoff; quote `request_id` if it persists. Codes: `archive_failed`, `archive_object_unavailable`, `archive_source_unavailable`, `cache_object_missing`, `manifest_file_unavailable`, `relay_attempt_cap`, `request_expired`, `settled_cache_missing`, `stripe_purchase_pending`, `stripe_reconcile_page_invalid`, `stripe_refund_conflict`, `stripe_refund_debt_conflict`, `stripe_refund_invalid`, `stripe_request_failed`, `stripe_response_invalid`, `transcript_not_ready`, `unexpected_error`, `webhook_dns_failed`. ### RelayUnavailable {#relay_unavailable} HTTP 502 · retryable. The transcript relay did not answer. Retry with backoff; the status page reports relay health. Codes: `archive_failed`. ### BudgetExhausted {#budget_exhausted} HTTP 503 · not retryable. New cold fetches are paused while the shared proxy budget refills; cached reads keep working. Bulk jobs pause and resume without intervention. Codes: none beyond the class default. ### InvalidInput {#invalid_input} HTTP 400 · not retryable. The request is malformed: a parameter, body field, path, query mode, or method. `code` names the field; correct the request before retrying. Codes: `archive_job_not_ready`, `archive_part_not_found`, `archive_source_changed`, `batch_tool_calls`, `bulk_input_required`, `code_expired`, `code_not_found`, `code_used`, `conflicting_query_modes`, `disposable_email`, `duplicate_archive_formats`, `empty_key_update`, `empty_list`, `invalid_archive_format`, `invalid_archive_part`, `invalid_code`, `invalid_credit_block`, `invalid_daily_credit_cap`, `invalid_email`, `invalid_format`, `invalid_grace_period`, `invalid_job_id`, `invalid_key_name`, `invalid_language`, `invalid_ledger_format`, `invalid_ledger_limit`, `invalid_ledger_offset`, `invalid_offset`, `invalid_purchase_id`, `invalid_request_id`, `invalid_usage_date`, `invalid_usage_format`, `invalid_usage_range`, `invalid_video_id`, `invalid_webhook_url`, `job_cancelled`, `job_not_found`, `job_size_cap`, `job_video_not_found`, `key_in_query_string`, `key_not_found`, `key_revoked`, `list_too_large`, `method_not_allowed`, `not_found`, `purchase_not_found`, `refund_block_used`, `refund_reason_too_long`, `request_not_found`, `selection_expired`, `single_video`, `single_video_required`, `stripe_account_unknown`, `stripe_body_invalid_utf8`, `stripe_body_too_large`, `stripe_content_length_invalid`, `stripe_customer_mismatch`, `stripe_dispute_mismatch`, `stripe_event_invalid`, `stripe_product_mismatch`, `stripe_refund_mismatch`, `stripe_session_mismatch`, `terms_required`, `unknown_query_mode`, `unsupported_media_type`, `usage_range_too_large`, `webhook_dns_empty`, `webhook_not_configured`, `webhook_threshold_invalid`, `webhook_url_invalid`, `webhook_url_private`. ### QuotaExceeded {#quota_exceeded} HTTP 429 · not retryable. A cap or throttle was reached. `retry_after` and the `Retry-After` header say when it clears: per-minute rates, daily caps at 00:00 UTC, key and job limits, sign-up throttles. Codes: `account_age_ramp`, `active_job_cap`, `cache_read_cap`, `code_locked`, `cold_fetch_cap`, `concurrency_cap`, `daily_credit_cap`, `data_api_unit_cap`, `enumeration_cap`, `free_pool_cap`, `global_cold_cap`, `key_limit`, `new_account_purchase_limit`, `purchase_in_progress`, `rate_limit`, `signup_rate_limited`, `verify_rate_limited`. ### TurnstileFailed {#turnstile_failed} HTTP 403 · not retryable. Part of the shared taxonomy for the free site’s bot check. Never returned to an API-key request. Codes: none beyond the class default. ### Forbidden {#forbidden} HTTP 403 · not retryable. Authentication failed (missing, malformed, revoked, or unknown key; missing dashboard header) or the caller may not use this route (transcript bodies are not served to crawlers). Codes: `account_closed`, `account_not_found`, `api_key_required`, `authentication_required`, `crawler_forbidden`, `dashboard_header_required`, `incorrect_code`, `invalid_api_key`, `invalid_authorization`. ### InsufficientCredits {#insufficient_credits} HTTP 402 · not retryable. The available balance cannot cover the work. The body carries `required`, `available`, `shortfall`, and `buy_url`. Jobs pause with `insufficient_credits` and resume after a purchase. Codes: `insufficient_credits`. ### AccountFrozen {#account_frozen} HTTP 403 · not retryable. The account is frozen (payment dispute, refund debt) or closed. Delivery stops; existing data is preserved. Contact support@scripthaul.com. Codes: `account_frozen`, `insufficient_credits`, `refund_debt`. ### ConfigurationError {#configuration_error} HTTP 503 · not retryable. Operator-side configuration is missing or incomplete (a price, a legal approval, an archive limit). Not caused by your request; retry later or contact support. Codes: `archive_entry_too_large`, `archive_formats_invalid`, `archive_memory_limit`, `archive_output_backlog`, `archive_part_too_large`, `billing_profile_missing`, `job_not_found`, `legal_review_required`, `stripe_request_failed`, `stripe_signature_invalid`. `TurnstileFailed` remains part of the shared free-site taxonomy but API-key requests do not use Turnstile. `ConfigurationError` identifies an operator-side configuration problem and is never a reason to expose secrets or retry in a tight loop. ## Inspect an error in four languages These examples deliberately request the unavailable mock fixture, receive `Unavailable` with HTTP 410, and keep its `request_id`. Production clients should branch on `errorClass`, not English text. Maintained runnable variants are in the [examples directory](/docs/examples). ```bash curl -i --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=aaaaaaaaaaa" ``` ```python import json, os, urllib.error, urllib.request request = urllib.request.Request("https://api.scripthaul.com/v1/transcript?video_id=aaaaaaaaaaa") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") try: urllib.request.urlopen(request) except urllib.error.HTTPError as error: print(json.loads(error.read())["errorClass"]) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/transcript?video_id=aaaaaaaaaaa", { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` }, }); const error = await response.json(); console.log(response.status, error.errorClass, error.request_id); ``` ```go req, _ := http.NewRequest("GET", "https://api.scripthaul.com/v1/transcript?video_id=aaaaaaaaaaa", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Webhooks `PUT /v1/webhooks` sets the account default endpoint and optional low-balance threshold. The response shows the new signing secret once; store it as carefully as an API key. Configure this default once before using a per-job `webhook_url`: that field overrides only the destination and keeps the account signing secret. A job override without an enabled account webhook is rejected before mutation with `webhook_not_configured`. `GET /v1/webhooks` returns only the current configuration without the secret; delivery state is included in job JSON. `DELETE /v1/webhooks` removes the default configuration and its signing secret. Already-enqueued deliveries retain the encrypted snapshot needed for their bounded retries. Only public `https:` destinations are accepted. ScriptHaul rejects credentials in URLs, localhost, private, loopback, link-local, metadata, mapped-private IPv6, private DNS answers, and its own hostnames (scripthaul.com, api.scripthaul.com, bulktranscripts.com). Redirects are never followed: a 3xx response counts as a failed attempt. Each attempt has a 10-second timeout. A failed event receives no more than five total attempts (the initial attempt plus four retries), scheduled from one minute through twelve hours. Bodies are never logged. Events are `job.completed`, `job.failed`, `job.paused`, `job.resumed`, and `credits.low`. Headers include `X-ScriptHaul-Event`, `X-ScriptHaul-Delivery`, `X-ScriptHaul-Timestamp`, and `X-ScriptHaul-Signature`. The signature is `v1=` followed by lowercase hex HMAC-SHA256 over the exact UTF-8 bytes `${timestamp}.${body}`. Verify before parsing, compare in constant time, reject timestamps outside a short replay window (the examples use five minutes), and make the delivery ID idempotent. ## Verify in Node ```javascript import { createHmac, timingSafeEqual } from "node:crypto"; const timestamp = request.headers.get("x-scripthaul-timestamp"); const supplied = request.headers.get("x-scripthaul-signature"); const body = await request.text(); const timestampSeconds = Number(timestamp); const nowSeconds = Math.floor(Date.now() / 1000); if (!Number.isSafeInteger(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > 300) throw new Error("stale webhook"); const expected = createHmac("sha256", process.env.SCRIPTHAUL_WEBHOOK_SECRET) .update(`${timestamp}.${body}`).digest("hex"); const candidate = supplied?.startsWith("v1=") ? supplied.slice(3) : ""; if (!/^[0-9a-f]{64}$/.test(candidate) || !timingSafeEqual(Buffer.from(candidate), Buffer.from(expected))) throw new Error("bad signature"); ``` ## Verify in Python ```python import hashlib, hmac, os, time timestamp = request.headers["X-ScriptHaul-Timestamp"] body = request.get_data(cache=False) try: timestamp_seconds = int(timestamp) except ValueError: raise ValueError("bad timestamp") if abs(int(time.time()) - timestamp_seconds) > 300: raise ValueError("stale webhook") expected = hmac.new(os.environ["SCRIPTHAUL_WEBHOOK_SECRET"].encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() supplied = request.headers.get("X-ScriptHaul-Signature", "") candidate = supplied[3:] if supplied.startswith("v1=") else "" if len(candidate) != 64 or not hmac.compare_digest(candidate, expected): raise ValueError("bad signature") ``` ## Read configuration in four languages Maintained runnable versions are in the [examples directory](/docs/examples). ```bash curl https://api.scripthaul.com/v1/webhooks -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" ``` ```python import os, urllib.request request = urllib.request.Request("https://api.scripthaul.com/v1/webhooks") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/webhooks", { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` } }); console.log(await response.json()); ``` ```go req, _ := http.NewRequest("GET", "https://api.scripthaul.com/v1/webhooks", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Languages and strict mode Every transcript and job row separates what you asked for from what YouTube delivered. Never infer a translation from the text alone; use the structured language object. | Field | Meaning | | --- | --- | | `requested` | Normalized language requested by the client. | | `delivered` | Language code on the caption track actually served. | | `kind` | `manual` or `asr` for auto-generated captions. | | `fallback` | `true` only when a different available language was deliberately substituted. | | `translation` | `not_requested`, `served`, `refused`, or `unavailable`. Job rows show `null` before delivery. | `refused` means YouTube's translation throttle declined the request this time and the source track was delivered instead; retrying later can succeed. `unavailable` means the video offers no translation into the requested language at all, so a retry cannot change the outcome. In strict mode the first is a retryable `translation_refused` error and the second a `language_unavailable` error listing `available_languages`; both cost 0. `fallback=true` permits a labelled substitute when the requested track cannot be delivered. `fallback=false` is strict mode: ScriptHaul returns an actionable language error with available languages and charges zero rather than silently switching. A translation refusal is also labelled; it is never presented as though the requested text arrived. Language matching uses normalized BCP 47-style tags. A base request such as `en` may match a compatible regional source such as `en-US`; the response still reports the delivered tag. Cache hits preserve the original track kind and translation verdict. ## Strict request in four languages Maintained runnable versions are in the [examples directory](/docs/examples). ```bash curl 'https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&language=es&fallback=false&format=json' -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" ``` ```python import os, urllib.request url = "https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&language=es&fallback=false&format=json" request = urllib.request.Request(url) request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&language=es&fallback=false&format=json", { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` }, }); console.log(await response.json()); ``` ```go req, _ := http.NewRequest("GET", "https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&language=es&fallback=false&format=json", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Status endpoint `GET /v1/status` is public and costs 0. It reports whether new fetches can run, plus relay, translation, capacity, serving-strategy, and canary state. It never returns relay addresses, credentials, proxy byte totals, or customer data. ```bash curl https://api.scripthaul.com/v1/status ``` `status: "degraded"` means new cold work is impaired or paused. Cached transcript reads may still work. Translation shedding does not by itself mark all plain transcript delivery degraded. The page at [api.scripthaul.com/status](/status) renders this endpoint. Its `latency` object reports the rolling 24-hour p50 and p95 upper bounds after 20 authenticated API-request observations. The values come from privacy-safe five-minute D1 histograms of measured Worker handling time; per-request events remain in Workers Analytics Engine. `transcript_cache_hits` contains successful responses explicitly labelled `HIT`; `transcript_cold` contains successful deliveries and bounded `202` hand-offs explicitly labelled `MISS`. Errors remain in the honest all-request measurement but in neither transcript cohort, so a validation or outage response cannot masquerade as cold-fetch latency. Before the minimum sample count, the percentile values are `null` and the page says it is still collecting measurements. ## Read health in four languages Status is public: none of these requests sends a credential. Maintained runnable variants are in the [examples directory](/docs/examples). ```bash curl https://api.scripthaul.com/v1/status ``` ```python import json, urllib.request with urllib.request.urlopen("https://api.scripthaul.com/v1/status") as response: status = json.load(response) print(status["status"], status["capacity"]) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/status"); const status = await response.json(); console.log(status.status, status.capacity); ``` ```go response, err := http.Get("https://api.scripthaul.com/v1/status") if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # MCP ScriptHaul has two deliberately different Streamable HTTP MCP endpoints. | Endpoint | Authentication | Intended use | | --- | --- | --- | | `https://scripthaul.com/mcp` | None | A small keyless taste: 20 cold fetches per address per UTC day; bulk links back to the free site. | | `https://api.scripthaul.com/mcp` | `Authorization: Bearer` | The account's REST limits, balance, metering, and keyed bulk jobs. | The keyless server is unchanged and never accepts an API key. The keyed server is registered as `com.scripthaul/api` and exposes five tools: `get_transcript`, `list_videos`, `get_bulk_download_link`, `create_bulk_job`, and `get_job`. The download-link tool only returns a prefilled link to the free bulk site; it does not create a keyed job. Transcript and listing behavior matches REST: cache hits, metadata, and enumeration cost zero; one successfully delivered cold transcript costs one credit. A keyed bulk call creates the same durable job as `POST /v1/jobs`, including reservations and account-scoped idempotency. `create_bulk_job` and `get_job` return the job envelope without per-video rows; page `GET /v1/jobs/{id}/videos` (with `status=error` for failures) when an agent needs them. Never put a key in the MCP URL or client arguments that may be logged. Use an HTTP header supplied from the client's secret or environment configuration. The server rejects query-string credentials. ```json { "mcpServers": { "scripthaul": { "type": "http", "url": "https://api.scripthaul.com/mcp", "headers": { "Authorization": "Bearer ${SCRIPTHAUL_API_KEY}" } } } } ``` ## Machine discovery Agents can discover the narrow ScriptHaul transcript skill at [`/.well-known/agent-skills/index.json`](/.well-known/agent-skills/index.json). Its SHA-256 digest pins the exact `SKILL.md` bytes. The skill says when to choose keyless MCP, keyed MCP, or REST and explicitly excludes video and audio download. The keyed remote publishes an experimental MCP server card at [`/mcp/server-card`](/mcp/server-card) with the same Bearer-header template as the client configuration above. The card format is an experimental MCP extension and may change. ## List keyed tools in four languages Maintained runnable versions are in the [examples directory](/docs/examples). ```bash curl https://api.scripthaul.com/mcp -X POST -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` ```python import json, os, urllib.request body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}).encode() request = urllib.request.Request("https://api.scripthaul.com/mcp", data=body, method="POST") request.add_header("Authorization", f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}") request.add_header("Content-Type", "application/json") print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const response = await fetch("https://api.scripthaul.com/mcp", { method: "POST", headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), }); console.log(await response.json()); ``` ```go body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`) req, _ := http.NewRequest("POST", "https://api.scripthaul.com/mcp", body) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) 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) ``` --- # Migrating from transcriptapi.com ScriptHaul uses Bearer authentication, explicit language results, free cache hits, and pay-as-you-go credit blocks. | Existing concept | ScriptHaul | | --- | --- | | API key query parameter | `Authorization: Bearer sh_live_…` | | YouTube URL parameter | Extract its 11-character YouTube ID and send it as `video_id` | | Plain transcript | `format=clean` | | Timestamped transcript | `format=timestamped` | | JSON transcript | `format=json` | | Translation language | `language=xx`; inspect requested/delivered/translation | | Silent language fallback | `fallback=true`, always labelled | | Strict language | `fallback=false`; refusal is retryable and costs 0 | | One credit for every call | 1 only for a successful cold delivery; cache hits and failures cost 0 | | Subscription/top-up balance | One-time blocks; purchased credits never expire | | Client-side channel loop | One durable bulk job with manifests, per-file downloads, events, and a signed webhook | ```bash curl --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" \ --data-urlencode "format=json" \ --data-urlencode "language=en" \ --data-urlencode "fallback=false" ``` Do not mechanically copy a key from a query string into a new URL. Move it into the header first, then verify your retry policy against typed `errorClass` and `retryable` fields. ## Equivalent request in four languages Each version uses an Authorization header, strict English, and JSON output. Maintained runnable variants are in the [examples directory](/docs/examples). ```bash curl --get https://api.scripthaul.com/v1/transcript \ -H "Authorization: Bearer $SCRIPTHAUL_API_KEY" \ --data-urlencode "video_id=jNQXAC9IVRw" --data-urlencode "format=json" \ --data-urlencode "language=en" --data-urlencode "fallback=false" ``` ```python import os, urllib.request url = "https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&format=json&language=en&fallback=false" request = urllib.request.Request(url, headers={"Authorization": f"Bearer {os.environ['SCRIPTHAUL_API_KEY']}"}) print(urllib.request.urlopen(request).read().decode()) ``` ```javascript const url = "https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&format=json&language=en&fallback=false"; const response = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SCRIPTHAUL_API_KEY}` } }); console.log(await response.json()); ``` ```go url := "https://api.scripthaul.com/v1/transcript?video_id=jNQXAC9IVRw&format=json&language=en&fallback=false" req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SCRIPTHAUL_API_KEY")) response, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ``` --- # Changelog Contract changes are listed by date. Subscribe to the [RSS feed](/docs/changelog.xml) to follow them. ## 2026-09-05 — Method handling, sitemap, and examples - Requests with a known path but an unsupported method now answer `405` with an `Allow` header and the standard error envelope (`method_not_allowed`); `OPTIONS` on a known path answers `204`. Unknown paths still answer `404`. - `docs_url` on errors links to the `errorClass` section of the errors page, which now lists every `code` the class can carry. - Every response carries `X-Request-Id`; API responses default to `Cache-Control: no-store`. - `GET /v1/usage?resource=ledger` and the dashboard aliases `PUT`/`DELETE /v1/account?resource=webhook` are documented in OpenAPI. Unknown `resource` or `action` values answer `400`. - A Bearer-authenticated client is never refused as a crawler, whatever its User-Agent. - `GET /v1/jobs/{id}/archive` answers `409` (`archive_failed`) instead of a `200` body when a build has exhausted its attempts. - Runnable curl, Node, Python, Go, and n8n examples are published at `/docs/examples`. - `/.well-known/api-catalog` is an RFC 9727 Linkset served as `application/linkset+json`. ## 2026-09-04 — v1 surface - Passwordless accounts, hashed Bearer keys with rotation and grace-period revocation, and dashboard sessions. - `GET /v1/transcript` in seven formats with explicit requested, delivered, fallback, and translation fields; `raw=1` attachments; `202` polling for long cold fetches. - `GET /v1/video` facts, `GET /v1/videos` channel and playlist listing with cached flags and cold counts, and `POST /v1/videos/resolve` for pasted lists. - Bulk jobs for up to 500 videos with exact reservation, settlement, pause, resume, retry, and cancel; manifests, per-file downloads, SSE progress, and queued ZIP archives with 80 MiB parts and seven-day expiry. - Signed webhooks with bounded retries, per-job endpoint overrides, and delivery state on the job. - Credits, free and paid limits, one-time Stripe credit blocks, self-service refunds, and automatic outage credits. - Public `GET /v1/status` with rolling p50/p95 latency; keyed MCP server; Agent Skill discovery and an experimental server card. ## Smoke-test the current surface The public status response is the safest post-upgrade smoke test. Maintained runnable variants are in the [examples directory](/docs/examples). ```bash curl https://api.scripthaul.com/v1/status ``` ```python import json, urllib.request with urllib.request.urlopen("https://api.scripthaul.com/v1/status") as response: status = json.load(response) print(status["status"], status["canary"]) ``` ```javascript const response = await fetch("https://api.scripthaul.com/v1/status"); const status = await response.json(); console.log(status.status, status.canary); ``` ```go response, err := http.Get("https://api.scripthaul.com/v1/status") if err != nil { log.Fatal(err) } defer response.Body.Close() io.Copy(os.Stdout, response.Body) ```