Use when wiring code to a real YouTube channel: user OAuth 2.0, resumable videos.insert uploads, editing metadata after publish, pulling views/watch time/retention/traffic from the Analytics API v2 into a dated 02-DOCS/wiki/youtube/ feedback log. NOT what to publish or how to title and thumbnail it (that is youtube-strategy / youtube-packaging).
Installs into .claude/skills of the current project.
Are you the author of Youtube Api?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-youtube-api)
---
name: youtube-api
description: "Use when wiring code to a real YouTube channel: user OAuth 2.0, resumable videos.insert uploads, editing metadata after publish, pulling views/watch time/retention/traffic from the Analytics API v2 into a dated 02-DOCS/wiki/youtube/ feedback log. NOT what to publish or how to title and thumbnail it (that is youtube-strategy / youtube-packaging)."
tags: [youtube, youtube-data-api, youtube-analytics-api, oauth2, resumable-upload, video-metadata, audience-retention, channel-feedback-log]
recommends: [social-publisher, api-connector-builder, automation-flows, knowledge-ops]
origin: risco
---
# 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 ~100 `search.list` calls (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:
1. Create (or pick) a **GCP project**.
2. **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 project` on the other.
3. 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.
4. Create an **OAuth client**: *Desktop app* for a personal/local script, *Web application* (with an exact redirect URI) for a hosted app.
5. 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:
```text
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
# 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)
```
```javascript
// 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:
1. **Init the session** — POST to the upload endpoint with `uploadType=resumable` and the metadata body. The response `Location` header is your session URL.
2. **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:
```python
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**.
```python
# 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:
```python
# (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:
- `elapsedVideoTimeRatio` runs 0.0–1.0 (playback position); `audienceWatchRatio` is the absolute retention at that point. Plot one against the other to find the drop-off.
- `insightTrafficSourceType` values include `YT_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.
```text
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):
```markdown
---
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" |