Cookbook

Six jobs people actually run against this API, written out in full. Every snippet is complete and runnable — copy it, set one environment variable, and it works.

Setup

Everything below assumes one environment variable and the base URL https://api.transcriptapi.io. Keys come from your dashboard; a new account starts with 20 free credits, which is enough to run recipes 2 through 5 end to end.

shell
export TRANSCRIPTAPI_KEY="ta_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# /health needs no key and costs nothing — a safe smoke test before a big job
curl -s "https://api.transcriptapi.io/health"
# {"status":"ok"}

curl -s "https://api.transcriptapi.io/transcript?video_id=dQw4w9WgXcQ" \
  -H "Authorization: Bearer $TRANSCRIPTAPI_KEY" | head -c 200
Three numbers worth memorising

One call is one credit. Translation adds one credit per 40 transcript segments, minimum 2. Cache hits and refunded failures cost nothing — which is why re-running a half-finished job is almost always cheaper than engineering around it.

What jobs cost

Cold means nothing has been fetched before. Warm means the same job ran already: transcripts are cached indefinitely, so the second run is served from cache and billed at zero.

JobCallsColdWarm
One transcript11 credit0
One transcript translated (20 min, ~400 segments)111 credits0
40-video playlist to a text corpus4141 credits0
Index 50 videos of a channel5151 credits0
Watch a channel, summarise each upload1 / upload1 credit per new video
/health, subscribe, unsubscribefreefree

The translated row is the only one with interesting arithmetic: 1 + max(2, ceil(segments / 40)). Segments run roughly one per three seconds of video, so a 20-minute talk lands near 400 segments and 11 credits, and a two-minute clip hits the floor at 3.

Recipe 01

Turn a playlist or course into one text corpus

  • Python
  • /playlist + /transcript
  • 1 + n credits

A conference track, a lecture series, a documentation channel — one playlist ID in, one searchable text file out. Two endpoints do the work: /playlist gives you the video IDs in order, /transcript gives you the words.

Start with the client wrapper. It is the same file every other recipe imports, and it encodes the one thing worth getting right: which failures are worth retrying.

ta.py
# ta.py — pip install httpx
import os
import time

import httpx

BASE = "https://api.transcriptapi.io"

client = httpx.Client(
    base_url=BASE,
    headers={"Authorization": f"Bearer {os.environ['TRANSCRIPTAPI_KEY']}"},
    timeout=httpx.Timeout(120.0),   # a long transcript with translation is slow
)


class OutOfCredits(RuntimeError):
    """402 — stop the whole job, no amount of retrying makes credits appear."""


class PermanentUpstream(RuntimeError):
    """502 for a reason that will never change: no captions, private, deleted."""


# The 502 detail carries the upstream exception name. These describe the video
# itself, so a retry only burns wall-clock time.
PERMANENT = (
    "TranscriptsDisabled",
    "NoTranscriptFound",
    "VideoUnavailable",
    "InvalidVideoId",
    "AgeRestricted",
)


def call(path: str, attempts: int = 4, **params):
    """One GET against the API, with retries only where they can help."""
    for attempt in range(attempts):
        r = client.get(path, params=params)

        if r.status_code == 200:
            return r.json()

        try:
            detail = r.json().get("detail", r.text)
        except ValueError:
            detail = r.text

        if r.status_code == 401:
            raise RuntimeError(f"bad or missing API key: {detail}")
        if r.status_code == 402:
            raise OutOfCredits(detail)
        if r.status_code == 422:
            raise ValueError(f"bad parameters for {path}: {detail}")
        if r.status_code == 502:
            if any(name in detail for name in PERMANENT):
                raise PermanentUpstream(detail)
            # Transient: YouTube blocked an exit node or timed out. The credits
            # were already refunded, so the retry is the only thing it costs.
            time.sleep(1.5 * (attempt + 1))
            continue

        r.raise_for_status()

    raise RuntimeError(f"{path} still failing after {attempts} attempts")
The job itself

Six workers is a reasonable default. There is no enforced rate limit today, but transcripts are fetched live from YouTube, so hammering with 50 threads mostly buys you more transient 502s rather than more throughput.

corpus.py
# corpus.py — python corpus.py PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab course.txt
import re
import sys
from concurrent.futures import ThreadPoolExecutor

from ta import call, OutOfCredits, PermanentUpstream


def flatten(segments) -> str:
    """Segments are 2-6 words each. Joined and de-spaced they read as prose."""
    return re.sub(r"\s+", " ", " ".join(s["text"].strip() for s in segments)).strip()


def fetch(video):
    """Returns (video, text, error). Only 402 is allowed to escape and kill the
    pooleverything else is a per-video problem, not a job-level one."""
    try:
        data = call("/transcript", video_id=video["id"], language="en")
        return video, flatten(data["transcript"]), None
    except OutOfCredits:
        raise
    except (PermanentUpstream, RuntimeError, ValueError) as exc:
        return video, None, str(exc)


def build(playlist_id: str, out_path: str, workers: int = 6):
    playlist = call("/playlist", playlist_id=playlist_id, limit=500)
    videos = playlist["videos"]
    print(f"{playlist['total']} videos, up to {1 + len(videos)} credits on a cold run")

    with ThreadPoolExecutor(max_workers=workers) as pool:
        # map keeps playlist order, which for a course is chapter order
        rows = list(pool.map(fetch, videos))

    skipped = []
    with open(out_path, "w", encoding="utf-8") as fh:
        for video, text, error in rows:
            if error:
                skipped.append((video["id"], video["title"], error))
                continue
            fh.write(f"\n\n### {video['title']}\n")
            fh.write(f"https://www.youtube.com/watch?v={video['id']}\n\n")
            fh.write(text + "\n")

    words = sum(len(t.split()) for _, t, e in rows if not e)
    print(f"wrote {out_path}: {len(rows) - len(skipped)} videos, ~{words:,} words")
    for video_id, title, error in skipped:
        print(f"  skipped {video_id} ({title}): {error}")


if __name__ == "__main__":
    try:
        build(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "corpus.txt")
    except OutOfCredits as exc:
        # Everything fetched so far is cached, so topping up and re-running
        # resumes for free rather than starting over.
        sys.exit(f"out of credits: {exc}")
Why re-running is the retry strategy

Every transcript you already fetched is cached server-side and served free. If the job dies at video 38 of 40, run it again — the first 37 cost nothing the second time. Building a local cache to avoid re-fetching optimises a bill you were not going to be charged.

Recipe 02

Build a searchable index of timestamped snippets

  • Python
  • TypeScript
  • SQLite FTS5
  • 1 + n credits

Full-text search over a channel, where every result is a link that opens YouTube at the exact second the phrase was said. The whole index is one SQLite file — no vector database, no embedding bill, and for keyword and phrase lookup it beats semantic search anyway.

The one design decision that matters is the size of the indexed unit. Raw segments are two to six words long, so indexing them directly means multi-word queries never match.

index.py
# index.py — a transcript search engine in one file, no services required
import re
import sqlite3

from ta import call, PermanentUpstream

DB = sqlite3.connect("transcripts.db")
DB.executescript("""
CREATE VIRTUAL TABLE IF NOT EXISTS snippets USING fts5(
    video_id UNINDEXED,
    title,
    start UNINDEXED,
    body
);
""")


def windows(segments, span: float = 30.0):
    """Group segments into ~30 second windows.

    A single segment is a fragment of a sentence, so indexing per segment means
    a phrase query never matches and every hit is unreadable. 30 seconds is
    long enough to hold a thought and short enough to jump to.
    """
    buf, t0 = [], None
    for seg in segments:
        if t0 is None:
            t0 = seg["start"]
        buf.append(seg["text"].strip())
        if seg["start"] + seg["duration"] - t0 >= span:
            yield t0, " ".join(buf)
            buf, t0 = [], None
    if buf:
        yield t0, " ".join(buf)


def index_channel(channel_id: str, limit: int = 50):
    listing = call("/channel/videos", channel_id=channel_id, limit=limit)

    for video in listing["videos"]:
        done = DB.execute(
            "SELECT 1 FROM snippets WHERE video_id = ? LIMIT 1", (video["id"],)
        ).fetchone()
        if done:
            continue

        try:
            data = call("/transcript", video_id=video["id"], language="en")
        except PermanentUpstream:
            continue   # captions disabled or the video is gone; nothing to index

        DB.executemany(
            "INSERT INTO snippets (video_id, title, start, body) VALUES (?, ?, ?, ?)",
            [
                (video["id"], video["title"], start, text)
                for start, text in windows(data["transcript"])
            ],
        )
        DB.commit()
        print(f"indexed {video['title']}")


def search(query: str, limit: int = 10):
    rows = DB.execute(
        """
        SELECT video_id, title, start,
               snippet(snippets, 3, '>>', '<<', '...', 14)
        FROM snippets
        WHERE snippets MATCH ?
        ORDER BY rank
        LIMIT ?
        """,
        (query, limit),
    ).fetchall()

    for video_id, title, start, hit in rows:
        at = max(0, int(start) - 2)   # land just before the phrase, not on top of it
        print(f"{title}  [{at // 60}:{at % 60:02d}]")
        print(f"  https://www.youtube.com/watch?v={video_id}&t={at}s")
        print(f"  {hit}\n")


if __name__ == "__main__":
    index_channel("@Fireship", limit=60)
    search("react server components")
Rendering a hit

YouTube honours &t= on a watch URL, in seconds. Rewind a couple of seconds before the match so the viewer hears the run-up instead of landing mid-word.

deep-link.ts
// Rendering a hit: the timestamp is the whole point, so make it clickable.
export type Hit = { videoId: string; title: string; start: number; body: string };

export function deepLink(videoId: string, start: number, lead = 2): string {
  const at = Math.max(0, Math.floor(start) - lead);
  return `https://www.youtube.com/watch?v=${videoId}&t=${at}s`;
}

export function stamp(start: number): string {
  const total = Math.floor(start);
  const pad = (n: number) => String(n).padStart(2, "0");
  const h = Math.floor(total / 3600);
  const m = Math.floor((total % 3600) / 60);
  const s = total % 60;
  return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}

// <a href={deepLink(hit.videoId, hit.start)}>{stamp(hit.start)}</a>

Re-running index_channel is cheap in two independent ways: the local SELECT 1 skips videos already indexed, and anything that does get re-requested comes back from the server cache for free. Pick up new uploads by simply running it again.

Recipe 03

Generate SRT and VTT subtitle files, including a translated track

  • Python
  • /transcript?translate_to=
  • 1 + max(2, n/40)

The API returns start and duration in seconds. Subtitle formats want a start and an end clock, which is a five-line conversion — plus one detail nobody warns you about: YouTube timings overlap, and most players render overlapping cues as a flicker.

subtitles.py
# subtitles.py — write .srt and .vtt for a video, plus a translated track
from math import ceil

from ta import call


def cues(segments):
    """(start, end, text) with overlaps removed.

    YouTube timings routinely overlap by a few hundred milliseconds. Players
    handle that as a flicker or a dropped line, so each cue is clamped to the
    start of the next one.
    """
    out = []
    for i, seg in enumerate(segments):
        start = float(seg["start"])
        end = start + float(seg["duration"])
        if i + 1 < len(segments):
            end = min(end, float(segments[i + 1]["start"]))
        if end <= start:
            end = start + 0.2
        text = " ".join(seg["text"].split())
        if text:
            out.append((start, end, text))
    return out


def clock(seconds: float, sep: str) -> str:
    """SRT wants 00:00:01,500 — VTT wants 00:00:01.500. Same digits, one comma."""
    ms = int(round(seconds * 1000))
    h, ms = divmod(ms, 3_600_000)
    m, ms = divmod(ms, 60_000)
    s, ms = divmod(ms, 1000)
    return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"


def to_srt(segments) -> str:
    blocks = [
        f"{i}\n{clock(a, ',')} --> {clock(b, ',')}\n{t}\n"
        for i, (a, b, t) in enumerate(cues(segments), start=1)
    ]
    return "\n".join(blocks)


def to_vtt(segments) -> str:
    blocks = ["WEBVTT\n"] + [
        f"{clock(a, '.')} --> {clock(b, '.')}\n{t}\n" for a, b, t in cues(segments)
    ]
    return "\n".join(blocks)


def translation_credits(segment_count: int) -> int:
    """What a translate_to call costs: 1 for the call, plus 1 per 40 segments
    with a floor of 2."""
    return 1 + max(2, ceil(segment_count / 40))


def write_tracks(video_id: str, translate_to: str | None = None):
    original = call("/transcript", video_id=video_id, language="en")
    segments = original["transcript"]

    for ext, body in (("srt", to_srt(segments)), ("vtt", to_vtt(segments))):
        with open(f"{video_id}.en.{ext}", "w", encoding="utf-8") as fh:
            fh.write(body)

    if not translate_to:
        return

    # The segment count is only known after the first fetch — and that fetch is
    # now cached, so it is not billed again when the translated call repeats it.
    print(f"{len(segments)} segments -> {translation_credits(len(segments))} credits")

    other = call(
        "/transcript", video_id=video_id, language="en", translate_to=translate_to
    )
    lang = other.get("translated_to", translate_to)
    with open(f"{video_id}.{lang}.vtt", "w", encoding="utf-8") as fh:
        fh.write(to_vtt(other["transcript"]))


if __name__ == "__main__":
    write_tracks("dQw4w9WgXcQ", translate_to="de")
Output
1 00:00:00,000 --> 00:00:03,520 We're no strangers to love 2 00:00:03,520 --> 00:00:06,500 You know the rules and so do I

For the translated track, ask for the same video with translate_to. The response carries translated_to so you can name the file from what you actually got back rather than what you asked for.

player.html
<video controls src="/clips/dQw4w9WgXcQ.mp4">
  <track kind="subtitles" srclang="en" label="English"
         src="/subs/dQw4w9WgXcQ.en.vtt" default>
  <track kind="subtitles" srclang="de" label="Deutsch"
         src="/subs/dQw4w9WgXcQ.de.vtt">
</video>
Each language is cached separately

The cache key includes both language and translate_to. Asking for German twice is free the second time; asking for French after German is a fresh translation and a fresh charge. Decide your target languages before you loop over a playlist.

Recipe 04

Watch a channel and auto-summarise every new upload

  • FastAPI
  • Express
  • /channel/subscribe
  • 1 credit / upload

Subscribing replaces polling. We check subscribed channels every 15 minutes and POST { channel_id, video_id } to your URL when something new appears. The first check after subscribing only records the current latest video, so notifications start from the next upload.

subscribe
# Subscribing and unsubscribing are free. Only the transcript fetch costs.
curl -X POST "https://api.transcriptapi.io/channel/subscribe" \
  -H "Authorization: Bearer $TRANSCRIPTAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{"channel_id":"UC_x5XG1OV2P6uZZ5FSM9Ttw","webhook_url":"https://you.example/hooks/yt/s3cr3t"}'

# {"status":"subscribed","channel_id":"UC_x5XG1OV2P6uZZ5FSM9Ttw", ...}

# Stop watching — channel_id goes in the query string for the DELETE
curl -X DELETE "https://api.transcriptapi.io/channel/subscribe?channel_id=UC_x5XG1OV2P6uZZ5FSM9Ttw" \
  -H "Authorization: Bearer $TRANSCRIPTAPI_KEY"

A receiver has three jobs: reject anything that is not yours, answer fast, and do the slow work afterwards.

hook.py
# hook.py — uvicorn hook:app --port 8080
# pip install fastapi uvicorn httpx openai
import os

import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from openai import OpenAI

from your_app import notify_ops, publish, retry_in   # your own plumbing

app = FastAPI()
llm = OpenAI()

HOOK_SECRET = os.environ["HOOK_SECRET"]
API_KEY = os.environ["TRANSCRIPTAPI_KEY"]

seen: set[str] = set()   # use Redis or a table once you have more than one worker


@app.post("/hooks/yt/{secret}")
async def on_new_video(secret: str, request: Request, background: BackgroundTasks):
    # Deliveries are unsigned, so the shared secret lives in the path and the
    # body is treated as a nudge to go fetch — never as trusted data.
    if secret != HOOK_SECRET:
        raise HTTPException(status_code=404)

    body = await request.json()
    video_id = body.get("video_id")
    if not video_id:
        raise HTTPException(status_code=400, detail="no video_id")

    if video_id in seen:
        return {"ok": True, "duplicate": True}
    seen.add(video_id)

    # Answer immediately; a webhook sender is not waiting for your summary.
    background.add_task(summarise, body.get("channel_id"), video_id)
    return {"ok": True}


async def summarise(channel_id: str, video_id: str):
    async with httpx.AsyncClient(timeout=120.0) as http:
        r = await http.get(
            "https://api.transcriptapi.io/transcript",
            params={"video_id": video_id},
            headers={"Authorization": f"Bearer {API_KEY}"},
        )

    if r.status_code == 402:
        return notify_ops(f"out of credits, skipped {video_id}")
    if r.status_code == 502:
        # Auto-captions can trail the upload by minutes. The credits were
        # refunded, so re-queueing this is free.
        return retry_in(minutes=20, channel_id=channel_id, video_id=video_id)
    r.raise_for_status()

    text = " ".join(s["text"] for s in r.json()["transcript"])
    if len(text) < 200:
        return   # a stub or a Short; not worth a model call

    reply = llm.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "Summarise this video transcript in five bullets. "
                           "Keep the speaker's claims and numbers, drop the filler.",
            },
            {"role": "user", "content": text[:120_000]},
        ],
    )
    publish(channel_id, video_id, reply.choices[0].message.content)
Same thing in Node
server.ts
// server.ts — npm i express
import express from "express";

const app = express();
app.use(express.json());

const API_KEY = process.env.TRANSCRIPTAPI_KEY!;
const seen = new Set<string>();

app.post("/hooks/yt/:secret", (req, res) => {
  if (req.params.secret !== process.env.HOOK_SECRET) return res.sendStatus(404);

  const { channel_id, video_id } = req.body ?? {};
  if (!video_id) return res.sendStatus(400);
  if (seen.has(video_id)) return res.json({ ok: true, duplicate: true });
  seen.add(video_id);

  res.json({ ok: true });                  // ack first
  void summarise(channel_id, video_id);    // then do the slow part
});

type Segment = { start: number; duration: number; text: string };

async function summarise(channelId: string, videoId: string) {
  const url = new URL("https://api.transcriptapi.io/transcript");
  url.searchParams.set("video_id", videoId);

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });

  if (!res.ok) {
    // 502 means it was refunded — put it back on the queue, do not drop it.
    console.error("transcript failed", res.status, await res.text());
    return;
  }

  const { transcript } = (await res.json()) as { transcript: Segment[] };
  const text = transcript.map((s) => s.text).join(" ");
  await handOffToYourModel(channelId, videoId, text);   // your own plumbing
}

app.listen(8080);
Captions arrive after the video does

Auto-generated captions can trail an upload by minutes or hours. A transcript request for a brand-new video often returns 502 with NoTranscriptFound — refunded, and worth retrying in 20 minutes. That is the difference between a transient 502 and a permanent one, and why the client wrapper inspects the detail string instead of blindly retrying.

Delivery is best-effort: no retries, no signature. Treat the payload as a hint, keep the secret in the path, and de-duplicate on video_id. If a summary genuinely must never be missed, reconcile once a day with /channel/videos.

Recipe 05

Feed transcripts to an LLM, chunked so answers cite a timestamp

  • Python
  • RAG
  • 1 credit / video

Chunk by timestamp, not by character count, and every retrieved passage knows where it came from. The model can then cite [412s], and you rewrite that into a link that opens the video at 6:52. An answer you can verify in one click is a different product from an answer you have to trust.

rag.py
# rag.py — chunks that carry their own clock
import re

from ta import call


def chunks(segments, target_words: int = 220, overlap_words: int = 40):
    """Split a transcript into overlapping windows, each keeping start and end.

    The overlap exists because a sentence that straddles a boundary is a
    sentence neither chunk can answer from.
    """
    out, buf, words = [], [], 0

    for seg in segments:
        buf.append(seg)
        words += len(seg["text"].split())
        if words < target_words:
            continue

        out.append(pack(buf))
        keep, kept = [], 0
        for s in reversed(buf):
            keep.insert(0, s)
            kept += len(s["text"].split())
            if kept >= overlap_words:
                break
        buf, words = keep, kept

    # Flush the tail, unless it is nothing but the overlap already emitted.
    if buf and (not out or words > overlap_words):
        out.append(pack(buf))
    return out


def pack(buf):
    last = buf[-1]
    return {
        "start": round(float(buf[0]["start"]), 2),
        "end": round(float(last["start"]) + float(last["duration"]), 2),
        "text": " ".join(s["text"].strip() for s in buf),
    }


def context(rows) -> str:
    """Each chunk is labelled with the second it starts at, so the model can
    cite it and you can turn the citation back into a link."""
    return "\n\n".join(f"[{int(c['start'])}s] {c['text']}" for c in rows)


CITATION = re.compile(r"\[(\d+)s\]")


def linkify(answer: str, video_id: str) -> str:
    return CITATION.sub(
        lambda m: f"[{m.group(1)}s](https://www.youtube.com/watch?v={video_id}&t={m.group(1)}s)",
        answer,
    )


def ask(video_id: str, question: str) -> str:
    from openai import OpenAI

    data = call("/transcript", video_id=video_id, language="en")
    rows = chunks(data["transcript"])

    reply = OpenAI().chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "Answer only from the transcript below. Every claim ends "
                           "with the [NNNs] marker of the chunk it came from. If the "
                           "transcript does not answer the question, say so.\n\n"
                           + context(rows),
            },
            {"role": "user", "content": question},
        ],
    )
    return linkify(reply.choices[0].message.content, video_id)


if __name__ == "__main__":
    print(ask("dQw4w9WgXcQ", "What promises does the singer make?"))
Do not build a vector store for one video

An hour of speech is roughly 9,000 words — about 12,000 tokens. That fits in any current model context, so for a single video, summarising or answering from the whole transcript is both cheaper and more accurate than retrieval. Chunking and embedding start to pay off when you are searching across a channel or a library, not a video.

Across a whole channel

Same chunker, one extra listing call, and the timestamp travels into the metadata so retrieval hits come back with a playable URL attached.

channel_rag.py
# Across many videos, embed the same chunks and keep the clock in the metadata.
from rag import chunks
from ta import call


def channel_chunks(channel_id: str, limit: int = 50):
    listing = call("/channel/videos", channel_id=channel_id, limit=limit)

    for video in listing["videos"]:
        data = call("/transcript", video_id=video["id"], language="en")
        for c in chunks(data["transcript"]):
            yield {
                "id": f"{video['id']}:{int(c['start'])}",
                "text": c["text"],
                "metadata": {
                    "video_id": video["id"],
                    "title": video["title"],
                    "start": c["start"],
                    "end": c["end"],
                    "url": f"https://www.youtube.com/watch?v={video['id']}&t={int(c['start'])}s",
                },
            }


# rows = list(channel_chunks("@Fireship", limit=50))
# collection.add(ids=[r["id"] for r in rows],
#                documents=[r["text"] for r in rows],
#                metadatas=[r["metadata"] for r in rows])
Recipe 06

A client wrapper that survives 402s, 502s and big batches

  • TypeScript
  • Zero dependencies
  • Reusable

The Python helper in recipe 1 has a TypeScript twin. It exists so that the rules of this API live in exactly one file: retry the transient failures, abort on the ones a retry cannot fix, and give every endpoint a typed response.

transcriptapi.ts
// transcriptapi.ts — every rule about this API in one place, zero dependencies
const BASE = "https://api.transcriptapi.io";

export type Segment = { start: number; duration: number; text: string };
export type TranscriptResponse = {
  video_id: string;
  transcript: Segment[];
  translated_to?: string;
};
export type SearchHit = {
  id: string;
  title: string;
  channel: string;
  views: string;
  duration: string;
};
export type ChannelVideo = {
  id: string;
  title: string;
  views: string;
  duration: string;
  published: string;
};

export class ApiError extends Error {
  constructor(readonly status: number, readonly detail: string) {
    super(`${status}: ${detail}`);
    this.name = "ApiError";
  }
}
export class OutOfCredits extends ApiError {}
export class BadRequest extends ApiError {}

const PERMANENT_UPSTREAM = [
  "TranscriptsDisabled",
  "NoTranscriptFound",
  "VideoUnavailable",
  "InvalidVideoId",
  "AgeRestricted",
];

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export class TranscriptAPI {
  constructor(
    private readonly key: string,
    private readonly retries = 3,
  ) {}

  private async get<T>(
    path: string,
    params: Record<string, string | number | undefined>,
  ): Promise<T> {
    const url = new URL(BASE + path);
    for (const [k, v] of Object.entries(params)) {
      if (v !== undefined) url.searchParams.set(k, String(v));
    }

    for (let attempt = 0; ; attempt++) {
      const res = await fetch(url, {
        headers: { Authorization: `Bearer ${this.key}` },
      });
      if (res.ok) return (await res.json()) as T;

      const detail = await res.text();

      // 401 and 422 are bugs in the caller. Retrying cannot fix a typo.
      if (res.status === 401 || res.status === 422) {
        throw new BadRequest(res.status, detail);
      }
      // 402 needs a top-up, not a backoff loop. Let it abort the whole job.
      if (res.status === 402) throw new OutOfCredits(402, detail);
      // 502 was refunded. Retry it unless the reason is about the video itself.
      if (res.status === 502 && PERMANENT_UPSTREAM.some((n) => detail.includes(n))) {
        throw new ApiError(502, detail);
      }
      if (attempt >= this.retries) throw new ApiError(res.status, detail);

      await sleep(400 * 2 ** attempt + Math.random() * 200);
    }
  }

  transcript(videoId: string, opts: { language?: string; translateTo?: string } = {}) {
    return this.get<TranscriptResponse>("/transcript", {
      video_id: videoId,
      language: opts.language ?? "en",
      translate_to: opts.translateTo,
    });
  }

  search(q: string, limit = 20) {
    return this.get<{ query: string; results: SearchHit[] }>("/search", { q, limit });
  }

  channelVideos(channelId: string, limit = 50) {
    return this.get<{ channel_id: string; title: string; total: number; videos: ChannelVideo[] }>(
      "/channel/videos",
      { channel_id: channelId, limit },
    );
  }

  channelSearch(channelId: string, q: string, limit = 20) {
    return this.get<{ channel_id: string; query: string; results: SearchHit[] }>(
      "/channel/search",
      { channel_id: channelId, q, limit },
    );
  }

  playlist(playlistId: string, limit = 100) {
    return this.get<{ playlist_id: string; total: number; videos: ChannelVideo[] }>(
      "/playlist",
      { playlist_id: playlistId, limit },
    );
  }
}
Bounded concurrency

A pool that stops the moment the balance runs out. Without the abort flag, a job that hits 402 on video 12 keeps firing another 200 doomed requests before anyone notices.

pool.ts
// A bounded pool that stops the moment the balance runs out.
export async function mapPool<T, R>(
  items: readonly T[],
  limit: number,
  fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
  const out = new Array<R>(items.length);
  let cursor = 0;
  let failure: unknown = null;

  const worker = async () => {
    while (cursor < items.length && failure === null) {
      const i = cursor++;
      try {
        out[i] = await fn(items[i], i);
      } catch (err) {
        failure = err;
      }
    }
  };

  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, worker),
  );
  if (failure !== null) throw failure;
  return out;
}

// Usage: re-running this after a crash costs nothing for the parts that
// already succeeded, because the server serves those from cache for free.
const api = new TranscriptAPI(process.env.TRANSCRIPTAPI_KEY!);
const { videos } = await api.playlist("PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab", 200);

try {
  const texts = await mapPool(videos, 6, async (v) => {
    const { transcript } = await api.transcript(v.id);
    return { id: v.id, title: v.title, text: transcript.map((s) => s.text).join(" ") };
  });
  console.log(`${texts.length} transcripts`);
} catch (err) {
  if (err instanceof OutOfCredits) {
    console.error("balance exhausted — top up and re-run, the rest is cached");
  } else {
    throw err;
  }
}

Failure modes, and what to do about each

Four status codes cover everything. The only one you should ever retry automatically is 502, and only when the reason is about the connection rather than the video.

StatusCauseCreditsDo
401Missing or unknown keyNot chargedFix the header. Never retry.
402Not enough credits for this callNot chargedAbort the job, top up, re-run — the rest is cached.
422Missing parameter or a limit out of rangeNot chargedFix the call. Check limit bounds.
502Upstream failed — blocked node, timeout, no captionsRefundedRead detail, then retry or skip.

A 502 body looks like Upstream request failed: TranscriptsDisabled. Your credits have been refunded. The exception name is the useful part. TranscriptsDisabled, NoTranscriptFound, VideoUnavailable, InvalidVideoId and AgeRestricted describe the video and will never succeed on retry — with the one exception of a just-published video, whose captions may not exist yet. Anything else is a connection problem and is worth two or three attempts with backoff.

A translation that you cannot afford fails mid-request: the base credit is charged, the translation charge trips the balance, and the whole request is rolled back and refunded. You get a 402 and no partial transcript.

Remember the limits when you write the limit parameter: /search and /channel/search accept 1–50, /channel/videos accepts 1–200, and /playlist accepts 1–500. Anything outside the range is a 422, not a silent clamp.

Full parameter and response documentation lives in the API reference.