Upload a video with videos in two requests. First send
POST https:// with the title
and settings as JSON. Then PUT the file's bytes to the address YouTube returns in the Location header. It needs an
OAuth 2.0 user access token with the https:// scope; an API key can't
upload. A Google Cloud project gets 100 uploads a day by default, in a quota bucket of their own, at 1 unit each.
A title holds up to 100 characters and a description up to 5,000 bytes.
Checked on November 3, 2026 against Google's videos.insert reference, its resumable upload guide, the quota calculator, the videos resource, the authorization guide and the revision history, and YouTube Help on Shorts and long uploads.
What you need first
- A Google Cloud project with the YouTube Data API v3 turned on, in the Google Cloud console.
- An OAuth 2.0 client ID from the project's Credentials page. An API key only reads public data, like a public video's details. YouTube doesn't support service accounts either.
- A user access token for the Google Account that owns the channel, with the
youtubescope. Ask for.upload access_type=to get a refresh token too. Access tokens expire; trade the refresh token for a new one atoffline https://. Google's web server guide walks through the sign-in.oauth2.googleapis .com/ token - A YouTube channel on that account. A Google Account with no channel gets
youtubeSignupRequired.
While your OAuth consent screen is in Testing for external users, Google's refresh tokens expire after 7 days.
The examples read the token from ACCESS_TOKEN and upload video. Keep the token out of your code and your logs.
Upload it
curl
SIZE=$(wc -c < video.mp4 | tr -d ' ')
# 1. Start the upload. YouTube answers 200 with the session's address in the Location header.
LOCATION=$(curl -s -D - -o /dev/null -X POST \
"https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json; charset=UTF-8" \
-H "X-Upload-Content-Length: $SIZE" \
-H "X-Upload-Content-Type: video/mp4" \
-d '{"snippet": {"title": "Hello from the YouTube API", "description": "Uploaded with videos.insert.", "categoryId": "22"},
"status": {"privacyStatus": "private", "selfDeclaredMadeForKids": false}}' \
| tr -d '\r' | awk 'tolower($1) == "location:" {print $2}')
# 2. Send the file. YouTube answers 201 Created with the new video.
curl -X PUT "$LOCATION" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: video/mp4" \
--upload-file video.mp4
The example uploads the video as private, so you can look at it in YouTube Studio first. Leave privacyStatus out
and it's public. YouTube answers with the video resource:
{
"kind": "youtube#video",
"id": "a1B2c3D4e5F",
"snippet": { "title": "Hello from the YouTube API", "categoryId": "22" },
"status": { "uploadStatus": "uploaded", "privacyStatus": "private", "selfDeclaredMadeForKids": false }
}
The video is at https://. YouTube still has to process it; read
processingDetails with videos until it says succeeded.
Python
import os
import requests
TOKEN = os.environ["ACCESS_TOKEN"]
FILE = "video.mp4"
metadata = {
"snippet": {"title": "Hello from the YouTube API", "description": "Uploaded with videos.insert.", "categoryId": "22"},
"status": {"privacyStatus": "private", "selfDeclaredMadeForKids": False},
}
start = requests.post(
"https://www.googleapis.com/upload/youtube/v3/videos",
params={"uploadType": "resumable", "part": "snippet,status"},
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json; charset=UTF-8",
"X-Upload-Content-Length": str(os.path.getsize(FILE)),
"X-Upload-Content-Type": "video/mp4",
},
json=metadata,
timeout=30,
)
start.raise_for_status()
with open(FILE, "rb") as f: # requests streams the file and sets Content-Length
resp = requests.put(
start.headers["Location"],
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "video/mp4"},
data=f,
timeout=300,
)
resp.raise_for_status()
print("https://www.youtube.com/watch?v=" + resp.json()["id"])
Google's own Python sample does the same with
its client library (google-api-python-client), using MediaFileUpload(..., resumable=.
Node.js (18 or later)
import { readFile } from 'node:fs/promises';
const token = process.env.ACCESS_TOKEN;
const video = await readFile('video.mp4');
const metadata = {
snippet: { title: 'Hello from the YouTube API', description: 'Uploaded with videos.insert.', categoryId: '22' },
status: { privacyStatus: 'private', selfDeclaredMadeForKids: false },
};
const start = await fetch(
'https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status',
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json; charset=UTF-8',
'X-Upload-Content-Length': String(video.length),
'X-Upload-Content-Type': 'video/mp4',
},
body: JSON.stringify(metadata),
},
);
if (!start.ok) throw new Error(`YouTube answered ${start.status}: ${await start.text()}`);
const resp = await fetch(start.headers.get('location'), {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'video/mp4' },
body: video,
});
if (!resp.ok) throw new Error(`YouTube answered ${resp.status}: ${await resp.text()}`);
console.log(`https://www.youtube.com/watch?v=${(await resp.json()).id}`);
This reads the whole file into memory, which suits short clips. For a large file, send it in parts (below).
If the upload stops partway
Don't start over. Ask the session how much YouTube has, with an empty PUT:
curl -i -X PUT "$LOCATION" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Length: 0" \
-H "Content-Range: bytes */$SIZE"
308 Resume Incomplete with Range: bytes= means YouTube has the first 1,000,000 bytes. No Range header
means it has none. Send the rest, starting at the next byte:
NEXT=1000000
tail -c +$((NEXT + 1)) video.mp4 | curl -X PUT "$LOCATION" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Range: bytes $NEXT-$((SIZE - 1))/$SIZE" \
--data-binary @-
If the full upload already finished, the same empty PUT returns YouTube's final answer again, such as the
201 with the video. Retry a 500, 502, 503 or 504 with exponential backoff. A 404 means the session
expired: start a new one. Each part you send must start right after the last byte YouTube has. To send a file in
chunks, make each chunk a multiple of 256 KB, except the last.
The catch: 100 uploads a day, for the whole project
Quota belongs to your Google Cloud project, not to a channel. Every person who uses your app shares it.
- Uploads have their own bucket. Since June 1, 2026,
videoscalls count apart from everything else. The default is 100 a day, at 1 unit each..insert - Everything else shares 10,000 units a day.
thumbnailsand.set videoscost 50 units each, and a.update videosread costs 1. Every request costs at least 1 unit, even one that fails..list - Quota resets at midnight Pacific time. Your usage is on the Quotas page in the Google Cloud console.
- More quota needs an audit. Fill in Google's Audit and Quota Extension Form. YouTube checks that the project follows the YouTube API Services Terms of Service.
Older guides say an upload costs 1,600 units, so 6 uploads a day. Google cut it to about 100 units in December 2025, then moved uploads to their own bucket.
Older guides also say a project that hasn't passed the audit can only upload private videos. That rule applied to projects created after July 28, 2020. Google ended it on November 2, 2026: uploads from unverified projects are no longer locked private.
Three errors look alike:
| Error | Means |
|---|---|
quotaExceeded (403) |
Your project used up today's quota. Wait for the reset at midnight Pacific |
uploadLimitExceeded (400) |
The channel reached its own daily upload limit. It's a YouTube limit on the channel, apart from your project's quota |
rateLimitExceeded (403) |
Too many requests in a short time. Slow down and retry |
Schedule, Shorts, made for kids, thumbnails
| To | Do this |
|---|---|
| Schedule it | Send "status": {"privacyStatus": "private", "publishAt": "2026-11-12T17:00:00Z"}. publishAt (ISO 8601) works only on a private video that was never published. YouTube makes it public at that time. A time in the past publishes it at once |
| Post a Short | There is no Shorts setting. Upload a square or vertical video up to 3 minutes long, and YouTube makes it a Short |
| Say whether it's made for kids | "status": {"selfDeclaredMadeForKids": true} or false. YouTube shows its own answer in status |
| Label altered or synthetic content | "status": {"containsSyntheticMedia": true} for realistic altered or synthetic content, like a real person shown saying something they didn't say |
| Make it unlisted | "privacyStatus": "unlisted" |
| Skip the subscriber notification | Add notifySubscribers= to the first request's query. It's true by default |
| Set a custom thumbnail | POST the JPEG or PNG bytes to https://, up to 50 MB, for 50 units. The channel must be verified. For a Short, wait until processing has succeeded |
How to schedule YouTube Shorts covers visibility and time zones, and YouTube Shorts length covers the 3-minute rule.
Limits
| Limit | |
|---|---|
| Video file | Up to 256 GB or 12 hours long, whichever is less. Type video/ or application/ |
| Video length | Up to 15 minutes, unless the account is verified |
| Title | 100 characters, required, no < or > |
| Description | 5,000 bytes, no < or >. Most emoji are 4 bytes in UTF-8 |
| Tags | 500 characters in all. Commas between tags count, and so do the quotes YouTube adds around a tag with a space |
| Uploads, per project | 100 videos calls a day by default, 1 unit each, in their own bucket |
| Other calls, per project | 10,000 units a day by default |
| Quota reset | Midnight Pacific time |
| Uploads, per channel | YouTube's own daily limit, higher once the channel is verified (uploadLimitExceeded) |
| Shorts | Square or vertical, up to 3 minutes |
| Custom thumbnail | JPEG or PNG, up to 50 MB, verified channel only. See YouTube thumbnail size |
Do it yourself, or let an agent draft it through Flaghand
Call the API yourself for a script on your own channel: one Google Cloud project, one sign-in and two requests per upload.
If an AI agent writes the videos' details, a token with youtube lets it publish on the channel at once, with
no one looking. Flaghand is a publishing desk for that case. You connect the channel with Google's own sign-in. Your
agent connects to Flaghand's MCP server (https://) or its API and drafts the upload: a video or a Short, with
its title, description, tags, a thumbnail, its visibility and YouTube's altered content and paid promotion labels.
Flaghand checks the title against YouTube's 100 characters, the description against 5,000 bytes and the tags as
YouTube counts them. It reads the video file's own shape and length to say whether YouTube will make it a Short. Before
a custom thumbnail or a video over 15 minutes, it asks YouTube whether the channel is verified. Flaghand takes video
files up to 50 MB.
Only you answer whether the video is made for kids, in the approval; nothing is preselected. You approve the exact upload with your passkey. A scheduled public video uploads right after you approve it, private, and YouTube makes it public at its time; other videos upload at their time. The upload is resumable and never sent twice. Flaghand then reads the video back and checks each field against what you approved. Flaghand's app shares Google's daily upload quota among the people who use it; when the day's uploads run out, an upload waits for the reset at 12:00 AM Pacific. No token Flaghand issues can approve, schedule or post. Sign-up takes no card (current terms). Get started with Flaghand.
Questions
Can I upload a video with a YouTube API key? No. An upload changes the channel, so it needs an OAuth 2.0 access
token from the channel's owner, with the youtube scope. An API key only reads public data.
How many videos can I upload a day with the YouTube API? By default, 100 per Google Cloud project, shared by
everyone who uses your app. Each videos costs 1 unit in its own quota bucket, and quota resets at midnight
Pacific time. For more, ask Google through its audit and quota extension form. Each channel also has its own daily
upload limit.
Why is my YouTube API upload private? Most often because the request set privacyStatus to private, or set
publishAt, which keeps it private until that time. Some uploads default to private because of the creator's account
settings; creators aged 13 to 17 upload private by default. Until November 2, 2026, uploads from unverified projects
created after July 28, 2020 were locked private; Google ended that rule.
How do I upload a YouTube Short with the API? The same way as any video: videos has no Shorts setting.
YouTube makes a Short of a square or vertical video up to 3 minutes long.
How do I schedule a YouTube video with the API? Upload it with "privacyStatus": "private" and publishAt set to
the time, in ISO 8601. YouTube makes it public then. publishAt only works on a private video that was never
published.
Can an AI agent upload to YouTube for me? With your token, it can upload and publish anything, immediately. Through Flaghand it drafts the upload and asks for approval, and only your passkey approval of the exact upload makes it go out.
Sources
Checked November 3, 2026:
- Google: Videos: insert
- Google: Resumable uploads
- Google: Quota calculator and quota and compliance audits
- Google: Videos resource (title, description, tags, status)
- Google: Thumbnails: set
- Google: Implement authorization and refresh token expiration
- Google: Errors
- Google: Revision history (the November 2, 2026 private rule change, the June 1, 2026 upload quota bucket and the December 4, 2025 upload cost change)
- YouTube Help: Upload YouTube Shorts
- YouTube Help: Upload videos longer than 15 minutes
- YouTube Help: Add custom thumbnails and feature access