Flaghand

Guides · Updated

How to upload a video with the YouTube API

Upload a video with videos.insert in two requests. First send POST https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status 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://www.googleapis.com/auth/youtube.upload 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

  1. A Google Cloud project with the YouTube Data API v3 turned on, in the Google Cloud console.
  2. 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.
  3. A user access token for the Google Account that owns the channel, with the youtube.upload scope. Ask for access_type=offline to get a refresh token too. Access tokens expire; trade the refresh token for a new one at https://oauth2.googleapis.com/token. Google's web server guide walks through the sign-in.
  4. 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.mp4. 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://www.youtube.com/watch?v=a1B2c3D4e5F. YouTube still has to process it; read processingDetails.processingStatus with videos.list 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=True).

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=0-999999 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, videos.insert calls count apart from everything else. The default is 100 a day, at 1 unit each.
  • Everything else shares 10,000 units a day. thumbnails.set and videos.update cost 50 units each, and a videos.list read costs 1. Every request costs at least 1 unit, even one that fails.
  • 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.madeForKids
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=false to the first request's query. It's true by default
Set a custom thumbnail POST the JPEG or PNG bytes to https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=…, 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/octet-stream
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.insert 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.upload 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://app.flaghand.com/mcp) 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.upload 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.insert 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.insert 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: