YouTube transcripts in LangChain, without the blocked loader

YoutubeLoader.from_youtube_url(...).load() ran fine in the notebook. Deployed, it raises RequestBlocked or IpBlocked on every video. This page replaces it with a loader that calls https://api.transcriptapi.io/transcript, keeps a timestamp on every chunk, and does the same job in LangChain.js and LlamaIndex.

Why the LangChain YouTube loader breaks on a server

YoutubeLoader in langchain_community is a thin wrapper. It imports YouTubeTranscriptApi from youtube-transcript-api for the transcript and pytube for the title and view count when you pass add_video_info=True. Both read YouTube's watch page directly, from whatever IP your code has. The LangChain.js version does the same through youtubei.js.

YouTube does not serve caption tracks to datacenter address ranges. AWS, GCP, Azure, Hetzner, Fly, Railway, a GitHub Actions runner: all of them get refused, usually within the first few requests. Nothing in LangChain is wrong. The request is coming from the wrong place. The library's own README says so and ships proxy support because of it; the details are on youtube-transcript-api blocked.

The fix is to move the fetch, not the framework. TranscriptAPI fetches through residential exits, absorbs YouTube's format changes, and returns the same start / duration / text segments the library would have given you. Everything downstream, splitters, embeddings, retrievers, stays as it is.

A drop-in LangChain loader that keeps timestamps

Two files. The first talks to the API and groups segments into time windows; the second is the BaseLoader that turns those windows into Documents. Splitting the fetch out lets the LlamaIndex reader below reuse it.

transcriptapi.py
# transcriptapi.py — one function, shared by every loader on this page
import os, time, requests

API = "https://api.transcriptapi.io"
HEADERS = {"Authorization": f"Bearer {os.environ['TRANSCRIPTAPI_KEY']}"}


def fetch_chunks(video_id: str, language: str = "en", window: int = 60) -> list[dict]:
    """The transcript of one video, grouped into `window`-second chunks.

    Returns [] when the video has no captions (404, refunded, retryable: false).
    Retries once when YouTube was unreachable (503, refunded, retryable: true).
    """
    for attempt in range(2):
        r = requests.get(
            f"{API}/transcript",
            params={"video_id": video_id, "language": language},
            headers=HEADERS,
            timeout=60,
        )
        if r.ok:
            break
        body = r.json()
        if body.get("retryable") is False:
            return []                 # captions off, private, deleted — credit refunded
        if body.get("retryable") is not True:
            r.raise_for_status()      # 401 / 402 / 422 — fix the request, not the video
        time.sleep(2 * (attempt + 1))
    r.raise_for_status()

    buckets: dict[int, list[dict]] = {}
    for seg in r.json()["transcript"]:            # [{"start": 0.0, "duration": 3.52, "text": "..."}]
        buckets.setdefault(int(seg["start"] // window), []).append(seg)

    chunks = []
    for segs in buckets.values():                 # insertion order == playback order
        start = segs[0]["start"]
        end = round(segs[-1]["start"] + segs[-1]["duration"], 2)
        chunks.append({
            "text": " ".join(s["text"] for s in segs),
            "video_id": video_id,
            "language": language,
            "start": start,
            "end": end,
            "source": f"https://www.youtube.com/watch?v={video_id}&t={int(start)}s",
        })
    return chunks

Chunking by time rather than by character count is deliberate. YouTube captions arrive as three-second fragments; a 60-second window is roughly 150 words, which is enough context for an answer and small enough that a citation points at one moment. YoutubeLoader's CHUNKS format does this too and sets start_seconds, start_timestamp and a source URL; the loader here adds end and the requested language, and keeps the field names short.

loader.py
from typing import Iterator
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from transcriptapi import fetch_chunks


class TranscriptAPILoader(BaseLoader):
    """Same shape as YoutubeLoader in CHUNKS mode, minus the datacenter block."""

    def __init__(self, video_id: str, language: str = "en", window: int = 60):
        self.video_id, self.language, self.window = video_id, language, window

    def lazy_load(self) -> Iterator[Document]:
        for c in fetch_chunks(self.video_id, self.language, self.window):
            yield Document(page_content=c.pop("text"), metadata=c)


# before:
#   YoutubeLoader.from_youtube_url(url, transcript_format=TranscriptFormat.CHUNKS,
#                                  chunk_size_seconds=60).load()
# after:
docs = TranscriptAPILoader("dQw4w9WgXcQ").load()

docs[3].metadata
# {'video_id': 'dQw4w9WgXcQ', 'language': 'en', 'start': 180.16, 'end': 239.8,
#  'source': 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=180s'}

One honest note on language: it is the language you asked for. The API falls back to English and then to whatever the video has, and the response does not echo which track was used. If you index in several languages, keep them in separate stores or pass translate_to to get everything into one language for one extra credit per 40 segments.

Build a corpus from a whole channel

A RAG index over one video is a demo. Over a channel it is a product. GET /channel/videos lists up to 200 newest uploads from a channel id or an @handle in one call; GET /search returns up to 50 results for a query, with ids ready to hand to the loader. Both cost one credit regardless of how many videos come back.

index_channel.py
import requests
from transcriptapi import API, HEADERS
from loader import TranscriptAPILoader


def channel_video_ids(channel: str, limit: int = 200) -> list[str]:
    """Newest uploads of a channel. channel is a UC… id or an @handle. 1 credit."""
    r = requests.get(f"{API}/channel/videos",
                     params={"channel_id": channel, "limit": limit},
                     headers=HEADERS, timeout=30)
    r.raise_for_status()
    return [v["id"] for v in r.json()["videos"]]


def search_video_ids(q: str, limit: int = 50) -> list[str]:
    """Top YouTube results for a query. 1 credit."""
    r = requests.get(f"{API}/search", params={"q": q, "limit": limit},
                     headers=HEADERS, timeout=30)
    r.raise_for_status()
    return [v["id"] for v in r.json()["results"]]


docs = []
for vid in channel_video_ids("@3blue1brown"):        # 1 credit
    docs.extend(TranscriptAPILoader(vid).load())    # 1 credit each; 0 on a cache hit

# any embeddings, any vector store — this is ordinary LangChain from here on
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS

store = FAISS.from_documents(docs, OpenAIEmbeddings())
for hit in store.similarity_search("why is e to the i pi minus one", k=3):
    print(hit.metadata["source"], "—", hit.page_content[:80])

For channels with more than 200 uploads, read the channel's uploads playlist through GET /playlist instead, which returns up to 500 videos per call. To keep the index current, use POST /channel/subscribe: we poll the channel every 15 minutes and POST {channel_id, video_id} to your URL when something new appears, so you load one video instead of re-listing the channel. Parameters for all of these are in the API reference.

The same loader in LangChain.js

@langchain/community ships a YouTube loader for Node that depends on youtubei.js, so it hits the same block on a server. This BaseDocumentLoader is the TypeScript version of the Python one above and emits identical metadata, so a Python indexer and a Node query service can share a store.

transcriptapi-loader.ts
import { BaseDocumentLoader } from "@langchain/core/document_loaders/base";
import { Document } from "@langchain/core/documents";

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

export class TranscriptAPILoader extends BaseDocumentLoader {
  constructor(private videoId: string, private window = 60) {
    super();
  }

  async load(): Promise<Document[]> {
    const res = await fetch(`https://api.transcriptapi.io/transcript?video_id=${this.videoId}`, {
      headers: { Authorization: `Bearer ${process.env.TRANSCRIPTAPI_KEY}` },
    });
    if (!res.ok) {
      const err = await res.json();
      if (err.retryable === false) return [];   // no captions — refunded, skip the video
      throw new Error(err.detail);              // 503 — let your job queue retry it
    }
    const { transcript } = (await res.json()) as { transcript: Segment[] };

    const buckets = new Map<number, Segment[]>();
    for (const s of transcript) {
      const k = Math.floor(s.start / this.window);
      buckets.set(k, [...(buckets.get(k) ?? []), s]);
    }
    return [...buckets.values()].map((segs) => {
      const last = segs[segs.length - 1];
      return new Document({
        pageContent: segs.map((s) => s.text).join(" "),
        metadata: {
          video_id: this.videoId,
          start: segs[0].start,
          end: last.start + last.duration,
          source: `https://www.youtube.com/watch?v=${this.videoId}&t=${Math.floor(segs[0].start)}s`,
        },
      });
    });
  }
}

// before: YoutubeLoader.createFromUrl(url, { language: "en" })
const docs = await new TranscriptAPILoader("dQw4w9WgXcQ").load();

LlamaIndex: a YouTube transcript reader

LlamaIndex's YoutubeTranscriptReader also imports youtube_transcript_api and returns one Document per video with only video_id in its metadata. This reader reuses fetch_chunks and gives you one node per time window with a stable id_, so re-indexing does not duplicate.

reader.py
from llama_index.core import Document, VectorStoreIndex
from llama_index.core.readers.base import BaseReader
from transcriptapi import fetch_chunks


class TranscriptAPIReader(BaseReader):
    """Replaces YoutubeTranscriptReader. One Document per time window, not per video."""

    def load_data(self, video_ids: list[str], language: str = "en") -> list[Document]:
        docs = []
        for vid in video_ids:
            for c in fetch_chunks(vid, language):
                text = c.pop("text")
                docs.append(Document(text=text, metadata=c, id_=f"{vid}:{int(c['start'])}"))
        return docs


index = VectorStoreIndex.from_documents(TranscriptAPIReader().load_data(["dQw4w9WgXcQ"]))
response = index.as_query_engine().query("What is promised in the chorus?")
for node in response.source_nodes:
    print(node.metadata["source"], node.score)

Agents: the no-code route

If the agent just needs to read a video on demand rather than query a prebuilt index, skip the loader. The TranscriptAPI MCP server exposes five tools, get_youtube_transcript, search_youtube, get_channel_videos, search_channel_videos and get_playlist_videos, over Streamable HTTP at https://api.transcriptapi.io/mcp or locally via npx -y transcriptapi-mcp. LangChain's MCP adapters, Claude, ChatGPT, Cursor and any other MCP client can call them directly. The transcript tool returns 30-second blocks with one timestamp each, which is what an agent needs to cite.

For pipelines without code at all, the same endpoints are wired up on the n8n, Make and Zapier pages.

Failures: skip the video, do not retry it

Some videos have no transcript. Captions turned off, private, deleted, age-restricted. The API answers 404, the credit is refunded before the response is sent, and the body tells you whether trying again could change anything:

404 response
HTTP 404
{
  "detail": "This video has captions turned off, so there is no transcript to fetch. Your credits have been refunded.",
  "error": "TranscriptsDisabled",
  "hint": "Retrying will not help — the uploader has disabled captions for this video.",
  "retryable": false,
  "credits_refunded": true
}

Branch on retryable, not on the text. false means the video is the problem: log it, return no Documents, move to the next id. That is what fetch_chunks does. true comes with a 503 and means YouTube refused our request that second; a fresh attempt goes out through a different exit and usually succeeds. Neither case costs a credit.

Do not wrap the loader in a blind retry

A retry decorator around YoutubeLoader was the habit, because the library raised the same exception for a blocked IP and a video with no captions. Here they are different status codes. Retrying a 404 five times just makes the job slower.

What indexing a 500-video channel costs

StepCallsCredits
List the uploads (/playlist, limit 500)11
Fetch 500 transcripts500500, minus one for every video with no captions
Re-run the indexer next week5000, transcripts are cached indefinitely
Load one new upload from the webhook11

Call it 500 credits. On the $10 pack of 2,500 credits that is about $2 for the whole channel, and you would index four more before buying again; the $49 Production plan covers 25,000 credits a month. At a 60-second window and a typical 12-minute video you end up with around 6,000 Documents to embed. The embedding bill is your provider's and is usually the larger number. Plans and packs are on the pricing page.

What this does not give you: no video or audio download, no comments, no descriptions or tags, and nothing for a video whose captions do not exist. If your pipeline transcribes audio with Whisper as a fallback, keep that path; this one is cheaper only when YouTube already has the text.

Try it on one channel

Sign in and copy a key: 20 free credits, no card, enough to list a channel and load the first nineteen videos through the loader above.

Frequently asked questions

Why does LangChain's YoutubeLoader work on my laptop but fail on AWS?

YoutubeLoader calls youtube-transcript-api, which reads the caption track from YouTube's watch page directly. YouTube refuses those requests from datacenter IP ranges (AWS, GCP, Azure, Hetzner, Fly, Railway, GitHub Actions), so the same code that works at home raises RequestBlocked or IpBlocked in production. The loader on this page sends the request to TranscriptAPI instead, which fetches through residential exits.

Can I keep YoutubeLoader and add a proxy instead?

Yes. youtube-transcript-api accepts a proxy configuration, and a residential proxy usually works. You then pay a second vendor by the gigabyte, maintain rotation and retry logic, and take the outage whenever YouTube changes its page format until the library ships a fix. Some teams prefer that. This page is for the ones that do not.

How do I get timestamps into RAG citations?

Chunk by time rather than by character count. Every chunk the loader emits carries start, end and a source URL with &t=<seconds>s, so when the model cites a chunk you can link straight to the moment in the video. Sixty seconds is a good default: about 150 words, enough context to answer from, small enough to point at.

What does it cost to index a whole channel?

One credit per video with a transcript, plus one credit per listing call. A 500-video channel is roughly 500 credits, or about two dollars on the $10 pay-as-you-go pack. Videos without captions are refunded, and re-running the indexer against the same videos is free because transcripts are cached indefinitely. Embedding cost is separate and depends on your provider.

Does this work with LlamaIndex and LangChain.js?

Yes. The page has a LlamaIndex BaseReader in twelve lines and a LangChain.js BaseDocumentLoader in TypeScript. Both use the same endpoint and produce the same metadata, so a Python indexer and a Node query service can share one store.

What happens when a video has no captions?

The API answers 404 with retryable: false, a plain-language hint, and the credit already refunded. The loaders here return an empty list for that video so the rest of the corpus keeps loading. A 503 with retryable: true means YouTube was temporarily unreachable; retry that one with backoff.