The short answer: a 403 from captions.download is the endpoint working as designed, not failing. Per Google's documentation, the YouTube Data API serves a caption download only when the request is authorized by the owner of the video (or an account with edit permission on it). An API key is never enough, more scopes won't help, and OAuth as yourselfdoesn't help for someone else's video — the check is about who owns the video, not how you authenticated. If it's your own video, the fix is the right OAuth setup, shown below. If it isn't, there is no fix inside the official API, by design — and the working alternatives are at the end.
What the endpoint actually requires
Two things trip almost everyone, and both are in the docs rather than in your code:
- Caption endpoints are OAuth-only. Both
captions.listandcaptions.downloadrequire an OAuth token with theyoutube.force-sslscope. A plain API key — the thing that works fine forvideos.listorsearch.list— returns401/403here no matter what. - Download is owner-gated. Even with a valid OAuth token,
captions.downloadchecks whether the authorized account owns (or can edit) the video the track belongs to. Google's documented 403 reason for this case isforbidden. Your token being valid, fresh, and correctly scoped changes nothing if the video belongs to someone else.
In other words: the endpoint exists for creators exporting captions from their own channel, and it enforces exactly that. The full background — including why the quota math makes the endpoint impractical at scale even for owners — is in Why the YouTube Data API won't give you transcripts.
Case 1: it's your own video — the OAuth setup that works
For your own uploads, the endpoint works and is the right, officially supported tool. The requests must be authorized by the channel that owns the video:
pip install google-api-python-client google-auth-oauthlibfrom google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/youtube.force-ssl"]
# client_secret.json comes from a Google Cloud project with the
# YouTube Data API enabled. Log in AS THE CHANNEL THAT OWNS THE VIDEO.
flow = InstalledAppFlow.from_client_secrets_file("client_secret.json", SCOPES)
creds = flow.run_local_server(port=0)
youtube = build("youtube", "v3", credentials=creds)
tracks = youtube.captions().list(
part="snippet", videoId="YOUR_VIDEO_ID"
).execute()
caption_id = tracks["items"][0]["id"]
srt = youtube.captions().download(id=caption_id, tfmt="srt").execute()
open("captions.srt", "wb").write(srt)Checklist when this still returns 403 on your own video: you logged into the OAuth consent screen with a different Google account than the channel owner (the classic one — a brand-account channel needs the brand account, not your personal login), or the scope is missing or narrower than youtube.force-ssl.
Things that don't fix it (so you can stop trying them)
- Service accounts.The YouTube Data API doesn't support service-account authorization for channel data — there is no way to "grant" a service account ownership of a channel. Server-to-server setups that work elsewhere in Google Cloud dead-end here.
- Requesting more scopes. The gate is video ownership, not scope breadth.
youtube.force-sslis already the widest relevant scope. - Retrying, backoff, new keys, new projects. A permissions 403 is deterministic. Unlike an IP block, it will return the same answer forever.
Case 2: it's not your video — what actually works
For arbitrary public videos the official API has no path — that is the design, not a gap you can configure around. The two working routes, with their honest trade-offs:
- The open-source library (
youtube-transcript-api) reads the public caption data YouTube's own player uses — no OAuth involved. Free and excellent for notebooks and low-volume work from a residential IP. Deployed to a datacenter it hits YouTube's IP blocks; that failure mode and its fixes are covered in the RequestBlocked / IpBlocked guide. - A managed pay-per-result actor — our YouTube Transcript Scraper — takes plain video IDs, needs no Google Cloud project, consent screen, or quota, and returns time-coded JSON at $3.00 per 1,000 delivered transcripts. A video that can't deliver (no captions, unplayable) becomes an unbilled
success: falserow with a stable code, and the batch keeps going. What it doesn't change: private, members-only, and age-gated videos stay out of reach for every method that doesn't own the video.
How the actor and the captions endpoints divide the territory — ownership, quota, cost, formats — is laid out dimension by dimension in the captions-endpoints comparison.
The 403 decision in one table
| Situation | The fix |
|---|---|
| Your own video | OAuth as the channel owner with youtube.force-ssl (code above) |
| Someone else's public video, prototype volume | youtube-transcript-api from a residential IP |
| Someone else's public video, unattended pipeline | A managed pay-per-result actor — no key, no quota, failures typed and $0 |
| Private / members-only / age-gated video | No method that doesn't own the video |
The pattern across both this 403 and the library's IP block is the same: each error marks the boundary of a stage. The official API ends at videos you own; the free library ends at datacenter deployment. Neither is broken — they're telling you which tool the next stage needs.