YouTube API — Transport + Ingestion for a Real Channel
You own the wire: authenticate to a YouTube channel, upload and edit videos, pull the numbers, and write those numbers into the wiki as a durable feedback log. You do not decide what to make or how to title it — that is the strategy/packaging family. Deliver clean transport and a queryable log; let the siblings interpret.
YouTube has two separate APIs and you will touch both:
- Data API v3 —
https://www.googleapis.com/youtube/v3— the write side:videos.insert(upload),videos.update(edit metadata),thumbnails.set. Default quota: a shared 10,000 units/day pool plus separate per-day caps of ~100 uploads and ~100search.listcalls (see §7). - Analytics API v2 —
https://youtubeanalytics.googleapis.com/v2/reports— the read side: views, watch time, retention, traffic sources. Separate scopes, separate quota.
Auth is user OAuth 2.0, not a service account. A human owns the channel; you act on their behalf with a refresh token. A service account cannot own a YouTube channel — if you reach for one, stop. (Service-account Google auth for Gmail/Drive/Sheets is google-workspace, a different model.)
Route elsewhere when the job is not transport:
| You actually want | Go to |
|---|---|
| What videos to make / niche / cadence / growth plan | ../youtube-strategy/SKILL.md |
| Video ideas, hooks, a topic backlog | ../youtube-ideation/SKILL.md |
| Title + thumbnail packaging, CTR copy, A/B framing | ../youtube-packaging/SKILL.md |
| The thumbnail image itself | ../youtube-thumbnails/SKILL.md |
| Render/produce the actual video file in code | ../remotion-video/SKILL.md |
| Post one asset across many networks at once | ../social-publisher/SKILL.md |
| Wrap an arbitrary REST provider with OAuth + retries | ../api-connector-builder/SKILL.md |
| Chain upload → Notion row → Slack across tools | ../automation-flows/SKILL.md |
| Service-account auth to Gmail/Drive/Sheets | ../google-workspace/SKILL.md |
| Generic wiki structure and conventions | ../knowledge-ops/SKILL.md |
1. One-time setup (do this before any code)
A checklist, because each missing step produces a distinct, confusing error later:
- Create (or pick) a GCP project.
- Enable both APIs in that project: "YouTube Data API v3" and "YouTube Analytics API". Enabling one is the most common cause of a
403 ... has not been used in projecton the other. - Configure the OAuth consent screen and publish it to "In production." Why: an app left in "Testing" status issues refresh tokens that expire in 7 days — your cron silently dies the following week. This is the single most common YouTube-ingestion breakage.
- Create an OAuth client: Desktop app for a personal/local script, Web application (with an exact redirect URI) for a hosted app.
- Request least-privilege scopes — not the full management scope.
Scope table — request only what the job needs:
| Scope | Grants | Use for |
|---|---|---|
https://www.googleapis.com/auth/youtube.upload |
Upload videos | videos.insert |
https://www.googleapis.com/auth/youtube.force-ssl |
Manage/edit/delete | videos.update, thumbnails.set |
https://www.googleapis.com/auth/yt-analytics.readonly |
Read performance | Analytics reports |
https://www.googleapis.com/auth/yt-analytics-monetary.readonly |
Read revenue metrics | only if you pull estimatedRevenue etc. |
Bad → Good on scopes:
Bad: scopes = ["https://www.googleapis.com/auth/youtube"] # full account control
Good: scopes = ["https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.force-ssl",
"https://www.googleapis.com/auth/yt-analytics.readonly"]
Full GCP + consent-screen walkthrough, Desktop-vs-Web choice, and unauthorized_client / access_denied / redirect-URI troubleshooting live in references/oauth-setup.md.
2. Get an authed client and keep the token alive
Build both service clients from one set of OAuth credentials.
# python: google-api-python-client + google-auth-oauthlib
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
import json, os
from google.oauth2.credentials import Credentials
SCOPES = ["https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.force-ssl",
"https://www.googleapis.com/auth/yt-analytics.readonly"]
TOKEN = "token.json" # gitignored — holds the refresh_token
def creds():
c = Credentials.from_authorized_user_file(TOKEN, SCOPES) if os.path.exists(TOKEN) else None
if not c or not c.valid:
if c and c.expired and c.refresh_token:
c.refresh(Request()) # silent renewal — needs "In production" app
else:
c = InstalledAppFlow.from_client_secrets_file(
"client_secret.json", SCOPES).run_local_server(port=0)
with open(TOKEN, "w") as f:
f.write(c.to_json())
return c
c = creds()
data = build("youtube", "v3", credentials=c)
yta = build("youtubeAnalytics", "v2", credentials=c)
// node: googleapis + google-auth-library
import { google } from "googleapis";
import fs from "node:fs";
const oauth2 = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
oauth2.setCredentials(JSON.parse(fs.readFileSync("token.json", "utf8"))); // { refresh_token, ... }
oauth2.on("tokens", (t) => { // persist the refreshed token
if (t.refresh_token) fs.writeFileSync("token.json", JSON.stringify(t));
});
const data = google.youtube({ version: "v3", auth: oauth2 });
const yta = google.youtubeAnalytics({ version: "v2", auth: oauth2 });
Rule: persist the refresh token, never a bare access_token. Access tokens expire in ~1 hour; the credential object refreshes itself. A hardcoded access_token=... literal is a guaranteed 401 within the hour — and is exactly what verify.sh flags.
3. Upload a video (resumable, always)
Decision: any file over a few MB → use the resumable protocol. A plain multipart POST drops the whole upload on one network hiccup; resumable survives it.
The protocol is two steps:
- Init the session — POST to the upload endpoint with
uploadType=resumableand the metadata body. The responseLocationheader is your session URL. - PUT the bytes to that session URL — one shot or in chunks. On a dropped connection, query with
Content-Range: bytes */*; the server returns the byte offset already received, and you resume from there.
The client libraries do this for you via a media-upload helper:
from googleapiclient.http import MediaFileUpload
body = {
"snippet": {"title": "Ep. 14", "description": "...", "tags": ["x", "y"], "categoryId": "27"},
"status": {"privacyStatus": "private", "publishAt": "2026-06-10T15:00:00Z",
"selfDeclaredMadeForKids": False},
}
media = MediaFileUpload("ep14.mp4", chunksize=8 * 1024 * 1024, resumable=True)
req = data.videos().insert(part="snippet,status", body=body, media_body=media)
resp = None
while resp is None:
status, resp = req.next_chunk() # resumes automatically on a transient drop
video_id = resp["id"]
data.thumbnails().set(videoId=video_id, media_body=MediaFileUpload("ep14.jpg")).execute()
Throttle the upload count, not the unit spend: videos.insert has its own cap of ~100 uploads/day, so uploadLimitExceeded (429) hits while the 10k unit pool still looks untouched. Space automated uploads out and back off on 429 — figures and citation in §7.
4. Edit metadata after publish
videos.update does read-modify-write semantics on whole parts. The trap: any field you omit inside a part you send gets cleared.
# Bad: wipes description and tags because they aren't in the body
data.videos().update(part="snippet",
body={"id": vid, "snippet": {"title": "New title", "categoryId": "27"}}).execute()
# Good: fetch the current snippet, change one field, send it back whole
cur = data.videos().list(part="snippet", id=vid).execute()["items"][0]["snippet"]
cur["title"] = "New title"
data.videos().update(part="snippet", body={"id": vid, "snippet": cur}).execute()
categoryId is required when you send a snippet part — another reason to read-modify-write. An update costs ~50 units.
5. Pull performance (Analytics API v2)
Every report is GET /v2/reports?ids=channel==MINE&startDate=&endDate=&metrics=...&dimensions=...&filters=.... Four recipes cover the loop:
# (a) Channel KPIs over a date range
yta.reports().query(ids="channel==MINE", startDate="2026-05-01", endDate="2026-05-31",
metrics="views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage"
).execute()
# (b) Per-video KPIs
yta.reports().query(ids="channel==MINE", startDate="2026-05-01", endDate="2026-05-31",
metrics="views,estimatedMinutesWatched,averageViewPercentage",
filters="video==VIDEO_ID").execute()
# (c) Audience-retention curve for one video
yta.reports().query(ids="channel==MINE", startDate="2026-05-01", endDate="2026-05-31",
metrics="audienceWatchRatio,relativeRetentionPerformance",
dimensions="elapsedVideoTimeRatio", filters="video==VIDEO_ID").execute()
# (d) Traffic sources
yta.reports().query(ids="channel==MINE", startDate="2026-05-01", endDate="2026-05-31",
metrics="views,estimatedMinutesWatched",
dimensions="insightTrafficSourceType").execute()
Notes that save an afternoon:
elapsedVideoTimeRatioruns 0.0–1.0 (playback position);audienceWatchRatiois the absolute retention at that point. Plot one against the other to find the drop-off.insightTrafficSourceTypevalues includeYT_SEARCH,SUGGESTED,BROWSE,EXT_URL,NOTIFICATION,PLAYLIST,END_SCREEN,NO_LINK_EMBEDDED.- Impressions / CTR (
impressions,impressionClickThroughRate) are content-owner-report metrics, not reliably available on a plain channel query. If the API returns nothing for them, fall back to the value Studio shows — do not block the pull.
Full metric+dimension catalog and copy-paste bodies for geography, device, and subscribed-status reports are in references/analytics-queries.md.
6. Ingest into the wiki — the actual deliverable
A pull that prints to stdout and vanishes is wasted. Every pull appends a dated entry under 02-DOCS/wiki/youtube/ so the channel's numbers become queryable history that the strategy/packaging siblings can read.
02-DOCS/wiki/youtube/
index.md # rolling pointer to latest snapshot + open questions
channel-2026-05-31.md # dated channel snapshot (one per pull)
videos/<VIDEO_ID>.md # per-video running log, newest entry on top
Per-pull entry template (OKF v0.1: non-empty type + the domain keys siblings parse):
---
type: youtube-metrics
title: Channel snapshot 2026-05-31
description: Channel KPIs, traffic and retention for 2026-05-01..2026-05-31.
tags: [youtube, channel-snapshot]
timestamp: 2026-05-31T00:00:00Z
date: 2026-05-31
range: 2026-05-01..2026-05-31
channel: MINE
source: youtube-analytics-api-v2
---
## KPIs
views: 41,233 | watch_min: 88,140 | avg_view_pct: 38.2% | avg_view_dur: 0:04:11
## Traffic (top 3)
BROWSE 44% · SUGGESTED 31% · YT_SEARCH 12%
## Retention
Sharp drop 0.00→0.06 (intro), recovers, second dip ~0.55.
## What changed since last pull
avg_view_pct +2.1pts; SUGGESTED share up 6pts after the Ep.13 packaging change.
Rule: append, never overwrite. The feedback log is the value — overwriting yesterday's snapshot destroys the trend the siblings need. 02-DOCS/wiki/ is an OKF v0.1 bundle: keep the domain keys (date, range, channel, source) the siblings parse, add type/timestamp alongside them, use standard markdown links (never [[wikilinks]]), and leave the reserved index.md frontmatter-free. Exact file tree, naming, per-video log shape, and how siblings read the log: references/wiki-schema.md.
7. Quota & failure math
| Call | Cost / allocation |
|---|---|
videos.insert (upload) |
own daily cap: ~100 uploads/day, not 10k-pool units |
search.list |
own daily cap: ~100 calls/day |
write: videos.update, thumbnails.set |
~50 units (from the 10k pool) |
*.list read |
1–5 units (from the 10k pool) |
Analytics reports.query |
(separate Analytics quota) |
Per the official docs (Last updated 2026-06-01 UTC, https://developers.google.com/youtube/v3/getting-started; per-method costs at https://developers.google.com/youtube/v3/determine_quota_cost), a project's default allocation is "100 search.list calls, 100 videos.insert calls, and 10,000 units per day combined for all other endpoints." All quotas reset at midnight Pacific. This is a change from the older single-pool model where videos.insert cost ~1,600 units — confirm the live figure on the quota-cost page before relying on it, since Google notes the allocation "is subject to change."
On 403 quotaExceeded / rateLimitExceeded or 429: exponential backoff with jitter, then stop for the day on a hard quota cap — retrying a daily-quota 403 in a tight loop just burns the next day too.
Error → cause map:
| Symptom | Cause | Fix |
|---|---|---|
| Refresh token dies after ~7 days | App still in "Testing" | Publish consent screen to "In production" |
unauthorized_client |
Consent/client misconfig or wrong client type | Re-check OAuth client + authorized scopes |
403 insufficient permissions |
Missing scope | Add the right scope, re-consent |
uploadLimitExceeded (429) |
Hit the ~100 videos.insert/day cap (separate from the 10k unit pool) |
Throttle upload count, back off |
Empty impressions/CTR |
Not a content-owner channel query | Read it from Studio |
403 ... API has not been used |
Other API not enabled | Enable both Data v3 and Analytics v2 |
Anti-patterns
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
Commit client_secret.json / token.json with refresh_token |
Leaks full channel control to anyone with repo read | Gitignore both; load from env/secret store |
Request full auth/youtube scope |
Grants total account control; fails review | Least set: youtube.upload + youtube.force-ssl + yt-analytics.readonly |
Hardcode an access_token |
Dead in ~1 hour | Persist the refresh token; use a refreshable credential |
videos.update with a partial part |
Omitted fields get wiped | Read-modify-write the whole part |
| Plain multipart upload for a big file | One drop kills the whole upload | Resumable protocol with chunked PUT |
| Treat the 10k unit pool as the upload budget | Uploads use a separate ~100/day call cap, so uploadLimitExceeded 429 hits with the pool untouched |
Throttle upload count against the ~100/day cap |
| Overwrite yesterday's wiki snapshot | Destroys the trend the siblings read | Append a new dated entry every pull |
| Leave the app in "Testing" | Token silently dies in 7 days | Publish to "In production" |