You can fetch a YouTube transcript with Scriptube's API in about 15 lines of Python, using nothing but the requests library. This walks through the short version, then how to paginate once a batch has more items than fit on one page.
There's no dedicated Scriptube Python SDK, and you don't need one â the API is plain JSON over HTTPS, and requests covers everything: sending the API key header, serializing the request body, and parsing the response. If you're coming from scraping YouTube's own page directly, the difference is that this always returns a transcript (when one exists) as structured data, not HTML you have to pick apart.
Requirements
- A Pro+ API key (
X-API-Keyheader) â see plans. - Python 3.8+ and
pip install requests. - A virtual environment is optional but recommended if this is going into a larger project:
python -m venv .venv && source .venv/bin/activatebefore thepip install.
Create a key from your dashboard's API keys page, or programmatically via POST /api/v1/keys â see key scopes, rotation, and revocation if you're generating keys from code rather than copy-pasting one.
The 15-line version
POST /api/v1/transcripts/resolve is the fastest path for a script: videos Scriptube already has come back inline, anything new gets a batch_id to check later.
import requests
HEADERS = {"X-API-Key": "YOUR_API_KEY"}
BASE = "https://scriptube.me"
r = requests.post(f"{BASE}/api/v1/transcripts/resolve", headers=HEADERS,
json={"urls": ["https://youtu.be/dQw4w9WgXcQ"]})
r.raise_for_status()
data = r.json()
for video in data.get("transcripts", []):
print(video["video_id"], "->", (video["transcript"]["text"] or "")[:80])
if data.get("batch_id"):
print("queued for processing:", data["batch_id"])
If the video was already cached, you get the text immediately in transcripts. If not, batch_id is set and the video is processing â poll GET /api/v1/transcripts/{batch_id} until status is completed, the same as any other batch. For the always-async version of submit, see the full API guide.
What each line is doing, in case you're adapting it: HEADERS carries your key on every call. The json= argument serializes the body and sets the content-type header for you â no need to do that by hand. r.raise_for_status() turns a 4xx/5xx response into a Python exception immediately, so a bad request fails loudly instead of silently returning garbage three lines later. data.get("transcripts", []) defaults to an empty list rather than raising a KeyError when every video in the request was new and nothing came back inline.
Saving results to files
Once a batch is done, export it straight to disk instead of reconstructing files from the JSON yourself (add import time alongside requests for the sleep below):
def download_when_ready(batch_id, fmt="txt", out_path="transcripts.txt"):
while True:
status = requests.get(f"{BASE}/api/v1/transcripts/{batch_id}", headers=HEADERS).json()["status"]
if status in ("completed", "partial", "failed", "cancelled"):
break
time.sleep(5)
export = requests.get(
f"{BASE}/api/v1/transcripts/{batch_id}/export",
headers=HEADERS,
params={"format": fmt},
)
export.raise_for_status()
with open(out_path, "wb") as f:
f.write(export.content)
return status
Swap fmt for srt, vtt, csv, json, or jsonl depending on what you need next â see the export schema reference for exact field names in the structured formats.
Paginating a large batch
A batch poll returns at most 50 items per page by default. Once a batch has more than that, loop on skip/limit until has_more is false:
# Paginate a large finished batch 50 items at a time
skip = 0
while True:
page = requests.get(f"{BASE}/api/v1/transcripts/{data['batch_id']}", headers=HEADERS,
params={"skip": skip, "limit": 50}).json()
for item in page["items"]:
print(item["video_id"], item["status"])
if not page["has_more"]:
break
skip += 50
This is the same pattern a 2,000-video channel batch on the Unlimited plan needs â see pagination for large batches for the full breakdown, including how it differs from paginating the list of batches themselves.
Handling the common errors
Wrap calls in a check for the error envelope instead of assuming 200:
if r.status_code == 402:
print("not enough credits:", r.json()["message"])
elif r.status_code == 429:
code = r.json()["code"] # e.g. rate_limited
print("throttled:", code, "retry after", r.headers.get("Retry-After"))
elif not r.ok:
print("error:", r.json())
A missing caption language doesn't error â the item still comes back with whatever track was available, plus a language_hint explaining the fallback. Check served_language against what you requested if your script needs to know whether it actually got the language it asked for, rather than assuming a 200 means an exact match.
A note on retries
If a network error means you genuinely don't know whether your /resolve call billed, don't just retry with a fresh request â generate a UUID once and send it as an Idempotency-Key header on both attempts. A replay of the same key returns the original result instead of creating (and charging for) a second batch. See safe retries with Idempotency-Key for the full pattern; the plain requests.post calls above don't need it for a first attempt, only for a retry after an ambiguous failure.
Running it as a scheduled script
For a daily job â download yesterday's uploads from a channel, for example â wrap the submit-and-poll pattern above in a plain function and run it from cron or a scheduled task runner. Keep the polling interval reasonable (5â10 seconds, not tight-looped) since reads are capped at 60 requests/minute per key; a script polling one batch doesn't come close to that, but a script polling many batches in a tight loop can. For anything that runs continuously rather than once a day, a registered webhook removes the polling loop entirely.
Next steps
- The complete API guide â auth, both submit endpoints, exports, rate limits.
- Webhooks vs. polling â skip the poll loop for scheduled jobs.
- Prefer Node? â a webhook receiver in TypeScript.
- Pagination in depth â the full breakdown of
skip/limit/has_more.
Full reference: API documentation.