# Youtube API

> 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).

- Skill: `ericrisco/youtube-api` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add ericrisco/youtube-api`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/youtube-api/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/youtube-api

---


# 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" |

