Migration guide

Two roads lead here. Either you are budgeting quota units against Google's 10,000-a-day ceiling and have hit the wall, or you are running youtube-transcript-api on your own servers and YouTube has started blocking their IPs. This page maps both onto https://api.transcriptapi.io, honestly — including the things it will not do for you.

Why people migrate

The YouTube Data API v3 is a good API for managing a channel you own. It is a poor API for reading YouTube at scale, and it cannot read captions for videos you do not own at all. The Python scraping libraries fill that gap right up until the moment your server IP gets flagged.

BeforeQuota units, OAuth, proxies

A Google Cloud project, an API key, an OAuth consent screen for anything user-scoped, a daily unit ceiling you cannot see the bottom of — or a scraper plus a residential proxy bill.

AfterOne token, one credit per call

Authorization: Bearer ta_… on a plain HTTPS request. One credit per billable call, cache hits free, failures refunded. 20 free credits on signup, no card.

Quota units vs credits

Google bills the Data API in quota units. A default project gets 10,000 units per day and the cost per call is wildly uneven: a read is usually 1 unit, but a search is 100. That single number is why so many projects stall — 10,000 units of pure search is 100 searches a day, for the whole project, across every user of it.

Data API v3 callQuota unitsCalls per day on the default 10,000
search.list100100
videos.list110,000
playlistItems.list110,000 (per page of ≤50)
channels.list110,000
captions.list50200 — and only for your own videos
captions.download20050 — and only for your own videos

You can apply for more quota. It is a form, a review, and a wait, and reading public data at volume is not a use case Google is enthusiastic about approving.

one modest day, priced both ways
# YouTube Data API v3 — 40 searches, then transcripts for the hits
search.list        x 40  =   4,000 units
videos.list        x 40  =      40 units   # for views + duration
captions.list      x 40  =   2,000 units
captions.download  x 40  =   8,000 units
                           -------------
                            14,040 units   # 40% over the daily ceiling
# ...and both captions calls 403 unless you own all 40 videos.

# TranscriptAPI — the same work
GET /transcript    x 40  =      40 credits
GET /search        x 40  =      40 credits
                           -------------
                                80 credits # no ceiling, no ownership rule

Credits do not reset at midnight and are not scoped to a project. Translation is the one call that costs more than 1: +1 credit per 40 transcript segments, minimum 2, because a two-hour video genuinely costs more to translate than a two-minute one.

Two things that are free

Cache hits never touch your balance — re-request the same transcript as often as you like. And if a call is charged and then fails upstream, you get a 502 and the credits come back automatically. There is no enforced request-rate limit today; one will be documented before it is introduced.

Keys, projects and OAuth

Getting to your first Data API request means: create a Google Cloud project, enable the YouTube Data API v3 on it, create an API key, restrict the key, and — for anything user-scoped, captions very much included — configure an OAuth consent screen, publish it or add test users, then run a browser consent flow and store and refresh the resulting tokens.

Here, there is one credential and it goes in one header. Keys have no scopes; the same key reaches every endpoint.

before — google-api-python-client
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow

# public reads: API key + a Cloud project
yt = build("youtube", "v3", developerKey=KEY)

# anything user-scoped: browser consent,
# token storage, refresh handling
flow = InstalledAppFlow.from_client_secrets_file(
    "client_secret.json", SCOPES)
creds = flow.run_local_server(port=0)
yt = build("youtube", "v3", credentials=creds)
after — TranscriptAPI
import os, requests

HEADERS = {
    "Authorization": f"Bearer {os.environ['TA_KEY']}"
}

# that is the whole setup
r = requests.get(
    "https://api.transcriptapi.io/health", timeout=10
)

Keep the key server-side. It is your balance, so calling the API straight from a browser or mobile client hands it to anyone who opens devtools — proxy through your own backend instead.

Endpoint mapping

Every read most people actually use the Data API for has a single equivalent here. Where a job took two or three chained calls, it usually takes one.

YouTube Data API v3UnitsTranscriptAPICreditsNotes
search.list (type=video)100GET /search1q, limit 1–50. Views and duration already on each result — no second videos.list.
search.list (channelId + q)100GET /channel/search1Matches against titles within one channel.
playlistItems.list1/pageGET /playlist1limit up to 500 in one response. No pageToken loop.
channels.list → uploads playlist → playlistItems.list1 + 1/pageGET /channel/videos1One call. Accepts a UC… ID, an @handle, or a custom URL slug — the Data API needs forHandle lookups first.
captions.list + captions.download50 + 200GET /transcript1Owner-only vs any public video. See below.
no equivalentGET /transcript?translate_to=+1/40 segMinimum 2. Accepts a code or a name (de, German).
polling, or PubSubHubbub you host1/pollPOST /channel/subscribe
DELETE /channel/subscribe
freeWe poll every 15 min and POST {channel_id, video_id} to your URL.
GET /healthfreeNo auth, no charge.

The captions problem

This is the part worth being blunt about, because it is the reason almost nobody uses the official API for transcripts.

captions.download is owner-only

The Data API can only download a caption track when the request is authorised by OAuth as the channel that owns the track. An API key is not enough; an OAuth token for some other account is not enough. For anyone else's video you get a 403 forbidden, every time, no matter how much quota you have left. That is not a rate limit you can raise — it is the design.

So the ecosystem routed around it. youtube-transcript-api, yt-dlp and friends read the same caption tracks the watch page does, without OAuth, because those tracks are already public. That works — until you run it from a datacentre IP.

GET /transcript returns the timed transcript of any public video, auto-generated or manually uploaded, with no ownership requirement and no OAuth. That is the whole pitch.

Python · google-api-python-client → requests

before — 100 units, then 1 more
from googleapiclient.discovery import build

yt = build("youtube", "v3", developerKey=KEY)

resp = yt.search().list(
    part="snippet",
    q="fastapi tutorial",
    type="video",
    maxResults=20,
).execute()                       # 100 units

ids = [i["id"]["videoId"] for i in resp["items"]]

# snippet has no views and no duration,
# so a second call is mandatory:
stats = yt.videos().list(
    part="statistics,contentDetails",
    id=",".join(ids),
).execute()                       # +1 unit
after — 1 credit, one round trip
import requests

r = requests.get(
    "https://api.transcriptapi.io/search",
    params={"q": "fastapi tutorial",
            "limit": 20},
    headers=HEADERS,
    timeout=30,
)
r.raise_for_status()
results = r.json()["results"]

# each result already carries
# id, title, channel, views, duration
ids = [v["id"] for v in results]

Same for playlists and channel uploads: replace the pageToken loop with a single limit — 500 for /playlist, 200 for /channel/videos.

Captions: before and after

Python · captions.download → GET /transcript

before — OAuth, owner-only, 250 units
# Only works if the OAuth token belongs to the
# channel that owns VIDEO_ID. Otherwise: 403.
creds = InstalledAppFlow.from_client_secrets_file(
    "client_secret.json",
    ["https://www.googleapis.com/auth/youtube.force-ssl"],
).run_local_server(port=0)

yt = build("youtube", "v3", credentials=creds)

tracks = yt.captions().list(
    part="snippet", videoId=VIDEO_ID,
).execute()                       # 50 units

srt = yt.captions().download(
    id=tracks["items"][0]["id"],
    tfmt="srt",
).execute()                       # 200 units
# then parse SRT into timings yourself
after — any public video, 1 credit
import requests

r = requests.get(
    "https://api.transcriptapi.io/transcript",
    params={"video_id": VIDEO_ID,
            "language": "en"},
    headers=HEADERS,
    timeout=60,
)
r.raise_for_status()

# already parsed: seconds, not SRT timecodes
segments = r.json()["transcript"]
# [{"start": 0.0, "duration": 3.52, "text": "..."}]

# want it in German? add translate_to="de"
# (+1 credit per 40 segments, min 2)

Coming from youtube-transcript-api

This is the easier migration of the two, because the data model is nearly identical. The library gives you a list of segments with text, start and duration; so does /transcript. The keys match. In most codebases you are swapping the fetch and leaving everything downstream alone.

before — self-hosted, with proxies
from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.proxies import (
    WebshareProxyConfig,
)

# without a residential proxy this raises
# IpBlocked / RequestBlocked from any cloud host
ytt = YouTubeTranscriptApi(
    proxy_config=WebshareProxyConfig(
        proxy_username=USER, proxy_password=PW,
    )
)

fetched = ytt.fetch(VIDEO_ID, languages=["en"])
segments = fetched.to_raw_data()

# on 0.6.x this was the classmethod form:
# YouTubeTranscriptApi.get_transcript(video_id)
after — same shape, no infrastructure
import requests

def fetch(video_id, language="en"):
    r = requests.get(
        "https://api.transcriptapi.io/transcript",
        params={"video_id": video_id,
                "language": language},
        headers=HEADERS,
        timeout=60,
    )
    r.raise_for_status()
    return r.json()["transcript"]

segments = fetch(VIDEO_ID)
# identical keys: text / start / duration

IP blocks and the proxy bill

The library is not the problem. Where it runs is. YouTube treats datacentre address ranges — AWS, GCP, Hetzner, Fly, Railway, basically anything that is not a home connection — as suspicious, and a scraper that works perfectly on your laptop starts throwing IpBlockedthe day you deploy it. The library's own documentation says as much, and ships proxy configuration because of it.

The standard fix is residential proxies, billed by the gigabyte. That is a recurring cost, a second vendor, a second failure mode, and a rotation strategy you now maintain. Plus the parsing itself: YouTube changes its player response shape periodically and you take the outage while you wait for a library release.

Handing that over is the actual reason to migrate. The IP reputation, the rotation, the parser upkeep and the retries become somebody else's on-call. What you keep is a bearer token and a function that returns segments.

When a block does happen

You get a 502 with a detail string, and the credits for that call are refunded automatically — you are never billed for an upstream failure. Retry with backoff and treat 502 as transient.

502 response
{
  "detail": "Upstream request failed: IpBlocked. Your credits have been refunded."
}

Response shape differences

Responses here are flat and small. There is no kind/etag envelope, no pageInfo, no nextPageToken, and no snippet nesting. Three differences will actually break code, so check for them before you swap:

FieldData API v3TranscriptAPI
Video IDitems[].id.videoId (search) or items[].id (videos)results[].id — always a plain string
Views"412345", a numeric string"412K views" — a display string. Parse it if you need an integer.
Duration"PT27M38S", ISO 8601"27:38" — a display string.
PublishedpublishedAt, RFC 3339 timestamppublished, relative ("10 years ago") on /channel/videos and /playlist; absent from /search.
PagingnextPageToken loop, 50 per pagelimit in one call — 50 search, 200 channel, 500 playlist
TranscriptSRT/VTT blob you parse yourselftranscript[] of {start, duration, text} in seconds
Display strings are the sharp edge

views and duration come back the way YouTube renders them, not as numbers. If you sort, filter or aggregate on either one, write a parser at the boundary — do not let "412K views" reach a comparison.

A small adapter

If you would rather not touch everything downstream, translate at the edge. Transcripts need no adapter at all — the keys already match youtube-transcript-api. Search results do:

adapters.py
import re

_MULT = {"K": 1_000, "M": 1_000_000, "B": 1_000_000_000}


def views_to_int(v):
    """'412K views' -> 412000. Returns None if unparseable."""
    m = re.match(r"([\d.,]+)\s*([KMB])?", v or "")
    if not m:
        return None
    n = float(m.group(1).replace(",", ""))
    return int(n * _MULT.get(m.group(2), 1))


def duration_to_seconds(d):
    """'27:38' or '1:02:11' -> seconds."""
    parts = [int(p) for p in (d or "0").split(":")]
    total = 0
    for p in parts:
        total = total * 60 + p
    return total


def as_search_list(payload):
    """Reshape GET /search into something a Data API
    consumer already understands. Fields the Data API
    has and we do not are simply omitted."""
    return {
        "kind": "youtube#searchListResponse",
        "items": [
            {
                "kind": "youtube#searchResult",
                "id": {"kind": "youtube#video",
                       "videoId": v["id"]},
                "snippet": {
                    "title": v["title"],
                    "channelTitle": v["channel"],
                },
                # extras, no Data API equivalent
                "viewCount": views_to_int(v["views"]),
                "durationSeconds": duration_to_seconds(v["duration"]),
            }
            for v in payload["results"]
        ],
    }

One caveat worth stating: an adapter can reshape data, it cannot invent it. Anything the Data API returned that these endpoints do not — descriptions, tags, thumbnails, category IDs, exact publish timestamps — stays missing. Read the next section before you write one.

What you lose

This API does a narrow set of things. If your integration depends on any of the following, it is not a drop-in replacement and you should keep the Data API alongside it, or not migrate at all.

  • Rich video metadata. No descriptions, tags, thumbnails, category IDs, licence, definition, live-broadcast status, or exact RFC 3339 publish timestamps. You get exactly the fields listed in the reference — nothing more.
  • Comments. No commentThreads, no comments. Nothing.
  • Analytics. No YouTube Analytics or Reporting API equivalent — no watch time, retention, demographics or revenue. Those are owner-scoped by nature and always will be.
  • Any write operation. No uploads, no metadata edits, no playlist creation, no caption insert or update, no rating, no moderation. This API is read-only.
  • OAuth user-scoped data.No "sign in with Google and read this user'ssubscriptions, playlists, likes or watch later". There is no user identity in the model at all — a key belongs to your account, not to a viewer.
  • Channel statistics. Subscriber counts, total channel views and video counts are not returned; /channel/videos gives you a title and the uploads.
  • Structured subscription feeds. The webhook is best-effort with no retry and no signature — a nudge to go fetch, not a delivery guarantee. Reconcile by polling if correctness matters.

Running both is a perfectly reasonable end state: the Data API for the metadata and write operations it is genuinely good at, this one for search at volume and for transcripts it cannot give you.

Migration checklist

  • Sign in and copy your key from the dashboard. 20 free credits, no card — enough to port and test a real integration.
  • Grep your codebase for googleapiclient, YouTubeTranscriptApi, yt_dlp and googleapis.com/youtube/v3. That list is your scope.
  • Check every call site against what you lose. Anything needing comments, analytics, writes or user-scoped OAuth stays on the Data API.
  • Put the key in an environment variable, server-side only, and build one HEADERS constant. Never ship it to a browser or a mobile binary.
  • Swap the reads using the mapping table. Delete every pageToken loop and replace it with a limit.
  • Add parsers for views and duration if you sort or filter on them, and drop publishedAt dependencies from search paths.
  • Handle the status codes: retry 502 with backoff (refunded automatically), alert on 402 when the balance runs out, fail loudly on 401 and 422.
  • Cancel the residential proxy subscription and delete the rotation code. That is usually the line item that pays for this.
  • Replace polling loops with POST /channel/subscribe where a 15-minute delay is acceptable — the webhook itself is free.
  • Watch the per-request credit costs in your dashboard for a day, then size a plan against real traffic rather than a guess.

The full parameter reference for every endpoint is in the API reference. If something in your migration does not map cleanly, the Discord link in the footer is the fastest way to reach engineering.