# 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](https://developers.google.com/youtube/v3/docs/videos/insert),
its [resumable upload guide](https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol), the
[quota calculator](https://developers.google.com/youtube/v3/determine_quota_cost), the
[videos resource](https://developers.google.com/youtube/v3/docs/videos), the
[authorization guide](https://developers.google.com/youtube/v3/guides/authentication) and the
[revision history](https://developers.google.com/youtube/v3/revision_history), and YouTube Help on
[Shorts](https://support.google.com/youtube/answer/12779649?hl=en) and
[long uploads](https://support.google.com/youtube/answer/71673?hl=en).

## What you need first

1. **A Google Cloud project** with the YouTube Data API v3 turned on, in the
   [Google Cloud console](https://console.cloud.google.com/apis/library).
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](https://developers.google.com/identity/protocols/oauth2/web-server)
   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

```bash
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:

```json
{
  "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

```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](https://developers.google.com/youtube/v3/guides/uploading_a_video) does the same with
its client library (`google-api-python-client`), using `MediaFileUpload(..., resumable=True)`.

### Node.js (18 or later)

```js
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`:

```bash
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:

```bash
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](https://developers.google.com/youtube/v3/guides/quota_and_compliance_audits).
  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](/docs/guides/how-to-schedule-youtube-shorts) covers visibility and time zones, and
[YouTube Shorts length](/docs/guides/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](/docs/guides/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](/pricing)).
[Get started with Flaghand](/schedule).

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

- Google: [Videos: insert](https://developers.google.com/youtube/v3/docs/videos/insert)
- Google: [Resumable uploads](https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol)
- Google: [Quota calculator](https://developers.google.com/youtube/v3/determine_quota_cost) and
  [quota and compliance audits](https://developers.google.com/youtube/v3/guides/quota_and_compliance_audits)
- Google: [Videos resource](https://developers.google.com/youtube/v3/docs/videos) (title, description, tags, status)
- Google: [Thumbnails: set](https://developers.google.com/youtube/v3/docs/thumbnails/set)
- Google: [Implement authorization](https://developers.google.com/youtube/v3/guides/authentication) and
  [refresh token expiration](https://developers.google.com/identity/protocols/oauth2#expiration)
- Google: [Errors](https://developers.google.com/youtube/v3/docs/errors)
- Google: [Revision history](https://developers.google.com/youtube/v3/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](https://support.google.com/youtube/answer/12779649?hl=en)
- YouTube Help: [Upload videos longer than 15 minutes](https://support.google.com/youtube/answer/71673?hl=en)
- YouTube Help: [Add custom thumbnails](https://support.google.com/youtube/answer/72431?hl=en) and
  [feature access](https://support.google.com/youtube/answer/9890437?hl=en)

[All guides](/docs/guides.md)
