If you’re building an automated video pipeline — AI-generated scripts, TTS voiceovers, programmatic video rendering, scheduled publishing — you need the YouTube Data API v3 to upload videos without manual interaction.
YouTube’s upload API requires OAuth 2.0 authorization for each channel. This means every channel in your matrix needs to go through a one-time authorization flow that produces a refresh token. Your automation server stores that token and uses it to upload videos on behalf of the channel, 24/7, no browser needed.
This guide walks you through the full setup: creating the Google Cloud project, configuring OAuth, authorizing multiple channels, and verifying uploads work — with all the gotchas called out.
What you’ll end up with:
- 1 Google Cloud project (shared across all channels)
- 1 OAuth client (Desktop type)
- 1 refresh token per YouTube channel, stored in your database
- A working upload flow you can call from any Python script or task queue
Step 1: Create Google Cloud Project
- Go to console.cloud.google.com
- Click the project dropdown (top bar) → New Project
- Name it something like
video-pipelineoryt-uploader - Create → wait 30s → select the new project from the dropdown
One project handles all your channels. You don’t need separate projects per account.
Step 2: Enable YouTube Data API v3
- In your project, go to APIs & Services → Library
- Search for YouTube Data API v3
- Click → Enable
- Optionally also enable YouTube Analytics API (useful later for pulling performance metrics)
Step 3: Configure OAuth Consent Screen
This is where most people get stuck. Follow exactly:
- Go to APIs & Services → OAuth consent screen
- Choose External (unless you have Google Workspace, then Internal works)
- Fill in:
- App name: anything descriptive (e.g.
Video Uploader) — end users won’t see this - User support email: your email
- Developer contact email: your email
- App name: anything descriptive (e.g.
- Click Save and Continue
Add Scopes
- Click Add or Remove Scopes
- Add these three scopes:
https://www.googleapis.com/auth/youtube.upload— upload videoshttps://www.googleapis.com/auth/youtube— manage channel (metadata, playlists)https://www.googleapis.com/auth/youtube.readonly— read channel info
- Save and Continue
Add Test Users
- Critical step: While your app is in “Testing” status, only explicitly listed test users can complete the OAuth flow. You must add every Google account that owns a YouTube channel in your matrix.
- Click Add Users → enter each Google account email
- Save and Continue → Back to Dashboard
Testing vs Production Mode
Your app starts in Testing mode. Two things to know:
- Refresh tokens expire after 7 days in Testing mode. Your automation breaks weekly unless you re-authorize.
- To fix this permanently, submit for Production verification. Google reviews it (days to weeks). Once approved, refresh tokens don’t expire.
- For an MVP or initial testing phase, just re-run the auth script when tokens expire. It takes 30 seconds per account.
Step 4: Create OAuth Client Credentials
- Go to APIs & Services → Credentials
- Click Create Credentials → OAuth client ID
- Application type: Desktop app ← do NOT choose “Web application”
- Name:
video-uploader(or whatever) - Click Create
- Download the JSON file → save as
client_secret.jsonin your project directory
This single JSON file is used for all channels. The per-channel differentiation happens at authorization time (Step 5), not here.
Why Desktop app and not Web? The Desktop flow uses a local redirect (
localhost) so you don’t need to set up a web server to receive the OAuth callback. You run it locally, approve in the browser, get your token, done.
Step 5: Authorize Each Channel
Run this script once per YouTube account. It opens a browser, you log in with the Google account that owns the channel, grant permissions, and get back a refresh token to store.
Install Dependencies
pip install google-api-python-client google-auth-oauthlib
Authorization Script
"""
youtube_oauth.py — Authorize a YouTube channel for API uploads.
Run once per channel. Log in with the Google account that owns the channel.
Outputs a JSON file with refresh token and channel info.
"""
import json
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
SCOPES = [
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube",
"https://www.googleapis.com/auth/youtube.readonly",
]
def authorize():
flow = InstalledAppFlow.from_client_secrets_file(
"client_secret.json", SCOPES
)
# Opens a browser window — log in and grant access
credentials = flow.run_local_server(port=8090)
# Verify: fetch channel info to confirm authorization worked
youtube = build("youtube", "v3", credentials=credentials)
response = youtube.channels().list(part="snippet", mine=True).execute()
if not response.get("items"):
print("ERROR: This Google account has no YouTube channel.")
print("Create a channel first at https://www.youtube.com/create_channel")
return
channel = response["items"][0]
channel_id = channel["id"]
channel_title = channel["snippet"]["title"]
print(f"\n✅ Authorized: {channel_title} ({channel_id})")
token_data = {
"channel_id": channel_id,
"channel_title": channel_title,
"access_token": credentials.token,
"refresh_token": credentials.refresh_token,
"token_uri": credentials.token_uri,
"client_id": credentials.client_id,
"client_secret": credentials.client_secret,
"scopes": list(credentials.scopes),
}
filename = f"tokens_{channel_id}.json"
with open(filename, "w") as f:
json.dump(token_data, f, indent=2)
print(f"💾 Tokens saved to {filename}")
print(f"🔑 Refresh token: {credentials.refresh_token[:20]}...")
print(f"\nStore this refresh token in your database for automated uploads.")
if __name__ == "__main__":
authorize()
Repeat for each channel: Run the script, log in with Google account #1, save tokens. Run again, log in with account #2, etc. Each run produces a separate tokens_{channel_id}.json file.
Step 6: Verify Uploads Work
Before wiring this into your pipeline, confirm each channel can actually upload.
Generate a Test Video
# 5-second black screen, silent — just for testing the upload flow
ffmpeg -f lavfi -i color=c=black:s=1080x1920:d=5 \
-f lavfi -i anullsrc \
-shortest -c:v libx264 -c:a aac \
test_video.mp4
Test Upload Script
"""
test_upload.py — Upload a test video to verify OAuth works.
Usage: python test_upload.py tokens_UCxxxx.json test_video.mp4
Uploads as PRIVATE so it won't appear publicly. Delete from YouTube Studio after.
"""
import sys
import json
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
def test_upload(token_file: str, video_file: str):
with open(token_file) as f:
tokens = json.load(f)
credentials = Credentials(
token=tokens["access_token"],
refresh_token=tokens["refresh_token"],
token_uri=tokens["token_uri"],
client_id=tokens["client_id"],
client_secret=tokens["client_secret"],
scopes=tokens["scopes"],
)
youtube = build("youtube", "v3", credentials=credentials)
body = {
"snippet": {
"title": "Test Upload — Delete Me",
"description": "Automated pipeline test. Safe to delete.",
"tags": ["test"],
"categoryId": "28", # Science & Technology
},
"status": {
"privacyStatus": "private", # Won't appear publicly
"selfDeclaredMadeForKids": False,
},
}
media = MediaFileUpload(video_file, chunksize=-1, resumable=True)
request = youtube.videos().insert(
part="snippet,status",
body=body,
media_body=media,
)
response = None
while response is None:
status, response = request.next_chunk()
if status:
print(f"Uploading... {int(status.progress() * 100)}%")
video_id = response["id"]
print(f"\n✅ Upload successful!")
print(f"🎬 Video ID: {video_id}")
print(f"🔗 URL: https://youtube.com/watch?v={video_id}")
print(f"\n(Uploaded as PRIVATE — delete from YouTube Studio when done)")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: python test_upload.py <token_file.json> <video_file.mp4>")
sys.exit(1)
test_upload(sys.argv[1], sys.argv[2])
Run for each channel:
python test_upload.py tokens_UCxxxx.json test_video.mp4
If you see “Upload successful” — that channel is ready for automation.
Using Tokens in Your Pipeline
Once tokens are in your database, your upload code reconstructs credentials and auto-refreshes:
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
def get_youtube_client(oauth_tokens: dict):
"""Build an authenticated YouTube client from stored tokens."""
credentials = Credentials(
token=None, # Will auto-refresh using refresh_token
refresh_token=oauth_tokens["refresh_token"],
token_uri=oauth_tokens.get("token_uri", "https://oauth2.googleapis.com/token"),
client_id=oauth_tokens["client_id"],
client_secret=oauth_tokens["client_secret"],
)
return build("youtube", "v3", credentials=credentials)
The google-auth library handles access token refresh automatically. As long as the refresh token is valid, you never need to open a browser again.
Quota & Limits
| Metric | Value |
|---|---|
| Daily quota per project | 10,000 units |
| Cost per video upload | ~1,600 units |
| Cost per metadata update | ~50 units |
| Safe daily uploads | ~6 videos |
| 5 accounts × 1 video/day | ~8,000 units ✅ |
| 10 accounts × 2 videos/day | ~32,000 units ❌ need quota increase |
To request more: Google Cloud Console → APIs & Services → YouTube Data API v3 → Quotas → Edit Quotas → fill in justification. Usually approved in 1–3 business days.
All uploads from all channels share the same project quota. Plan accordingly when scaling.
Troubleshooting
“Access blocked: This app’s request is invalid” → You selected “Web application” instead of “Desktop app” in Step 4. Delete the credential and recreate as Desktop.
“The caller does not have permission” → The Google account isn’t listed as a Test User in Step 3. Add it, then retry.
“Token has been expired or revoked”
→ Your app is in Testing mode and 7 days have passed. Re-run youtube_oauth.py for that account. To fix permanently, submit for Production verification.
“quotaExceeded” → You’ve hit the 10,000 unit daily limit. Resets at midnight Pacific time. Request a quota increase if you need more.
Upload succeeds but video shows “Processing failed” in YouTube Studio
→ Video file is corrupted or uses an unsupported codec. YouTube requires H.264 video + AAC audio in an MP4 container. Verify with: ffprobe your_video.mp4
“The request metadata specifies an invalid video description” → Description exceeds 5,000 characters, or contains URLs that YouTube blocks. Keep descriptions under 4,000 chars.
“Daily Limit for Unauthenticated Use Exceeded”
→ Your credentials aren’t being sent correctly. Check that refresh_token is not null in your stored tokens.
Security Notes
- Never commit
client_secret.jsonor token files to git. Add both patterns to.gitignore. - Refresh tokens are sensitive. Treat them like passwords. Store encrypted in your database.
- One OAuth client for all channels is fine. The per-channel identity comes from the refresh token, not the client.
- Revoking access: If an account is compromised, revoke at myaccount.google.com/permissions — this invalidates the refresh token immediately.