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).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill youtube-api --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Youtube Api?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-youtube-api)More formats (shields.io, HTML) on the badges page.
---
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" |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!