updated 1 November 2026

The YouTube upload API, explained

To upload a video from code, send a POST to /upload/youtube/v3/videos on www.googleapis.com (the YouTube Data API's videos.insert method) with an OAuth access token that has the youtube.upload scope. Use the resumable protocol: one request sends the video's metadata and returns a session URI, and the file goes to that URI in one or more PUT requests. Since June 2026 uploads have their own quota bucket: a project gets 100 videos.insert calls a day by default, and each costs 1 unit.

What videos.insert does

videos.insert is the only Data API method that creates a video. It uploads the file and sets the video's metadata in the same operation (Videos: insert). The file can be up to 256 GB, with a MIME type of video/* or application/octet-stream. The request body is a video resource, and these are the fields you can set at upload time:

PartWritable fields
snippettitle, description, tags[], categoryId, defaultLanguage
statusprivacyStatus, publishAt, selfDeclaredMadeForKids, containsSyntheticMedia, embeddable, license, publicStatsViewable
Otherlocalizations, recordingDetails.recordingDate

The part query parameter names the parts you set, and the same parts come back in the response. notifySubscribers defaults to true; set it to false when a channel uploads many videos at once. There is no Shorts flag: the request is the same for any video.

If you leave out privacyStatus, the video is public, although some channel settings make uploads private by default. To schedule a release, upload with privacyStatus set to private and an ISO 8601 publishAt time, and YouTube makes the video public at that time.

OAuth scopes and tokens

Uploads act on a channel, so they need the channel owner's OAuth 2.0 authorization. An API key isn't enough, and the Data API supports service accounts only for YouTube content owners who manage many channels (authentication). videos.insert accepts any of these scopes:

ScopeWhat else it allows
youtube.uploadNothing else: upload only. Ask for this one if uploading is all you do.
youtubeFull management of the channel
youtube.force-sslFull management, including comments and captions
youtubepartnerYouTube content partner (CMS) access

All four live under https://www.googleapis.com/auth/. A server that uploads later, without the user present, should request offline access to get a refresh token (OAuth 2.0 for web server apps). One catch while you build: an OAuth app with an external user type and a publishing status of "Testing" gets refresh tokens that expire after 7 days (Using OAuth 2.0), so expect to reconnect test channels weekly until the app is published.

The upload quota

On 1 June 2026 Google moved videos.insert and search.list to their own quota buckets (revision history). These are a project's default daily allowances (quota costs):

BucketDefault per day
videos.insert100 calls, 1 unit each
search.list100 calls, 1 unit each
Every other method10,000 units combined
videos.batchGetStats10,000 units in its own bucket (added 3 June 2026)

Quotas reset at midnight Pacific Time, and every request counts, including invalid ones. Calls around an upload still come out of the shared 10,000 units: videos.list costs 1, videos.update 50, and thumbnails.set 50. Many tutorials still say an upload costs about 1,600 units. Google cut that to about 100 units in December 2025 and to 1 call in its own bucket in June 2026. To go past 100 uploads a day, request more through the Quota Extension Form, which Google pairs with an API compliance audit.

A note on private uploads: in 2020 Google said videos uploaded through videos.insert from unverified API projects created after 28 July 2020 would be locked to private until the project passed an audit (revision history). The current videos.insert reference, last updated 15 October 2026, says videos from unverified projects are not restricted to private viewing mode. If your public uploads come out private, check the project's audit status and the channel's default upload settings.

Resumable uploads, step by step

Google's resumable protocol lets an upload continue after a dropped connection instead of starting over (resumable uploads). Google's client libraries, such as googleapis for Node.js, handle it for you; the raw HTTP looks like this.

1. Start a session. POST the video resource with uploadType=resumable, the file's size in X-Upload-Content-Length, and its type in X-Upload-Content-Type. A 200 response carries the session URI in its Location header.

curl -i -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: 20000000" \
  -H "X-Upload-Content-Type: video/mp4" \
  --data '{
    "snippet": { "title": "Release notes", "description": "What changed this week." },
    "status": { "privacyStatus": "private", "selfDeclaredMadeForKids": false }
  }'
# HTTP 200. The Location header holds the session URI:
# Location: https://www.googleapis.com/upload/youtube/v3/videos?...&upload_id=...

2. Send the file. PUT the bytes to the session URI. A 201 means the upload finished, and the body is the new video resource with its ID.

curl -i -X PUT "<SESSION_URI>" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: video/mp4" \
  --data-binary @video.mp4
# HTTP 201. The body is the new video resource:
# {"id": "...", "status": {"uploadStatus": "uploaded", ...}}

3. If the connection drops, ask where it stopped. Send an empty PUT with Content-Range: bytes */TOTAL. A 308 Resume Incomplete response's Range header shows the bytes YouTube has, counted from 0. No Range header means nothing arrived yet. If the upload already finished, you get the original final response again.

curl -i -X PUT "<SESSION_URI>" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Length: 0" \
  -H "Content-Range: bytes */20000000"
# HTTP 308 Resume Incomplete
# Range: bytes=0-8388607
# The first 8,388,608 bytes arrived, so resume at byte 8388608.

4. Resume from the next byte. The first byte you send must be exactly one past the end of that range. Overlapping or skipping bytes uploads nothing.

curl -i -X PUT "<SESSION_URI>" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Range: bytes 8388608-19999999/20000000" \
  --data-binary @rest-of-video.bin

Resume after a lost connection or a 500, 502, 503, or 504, with exponential backoff, and honor Retry-After if the response has one. Other 4xx and 5xx codes are permanent failures. Session URIs expire: an expired one returns 404, and you have to start a new session and upload from the beginning. One exception: if you had sent the last byte and lost the response, the video may already exist, so look for it in the channel's uploads before you upload again. If you send the file in chunks, every chunk except the last must be a multiple of 256 KB and the same size. Google calls chunking rarely necessary, though it does let you show progress and limits how much a dropped request costs you.

Treat the session URI as a secret and save it before sending bytes. A process that crashes mid-upload and starts a fresh session instead of resuming creates a second video and spends a second call from the 100-a-day bucket.

After the upload: processing

A 201 means YouTube has the file, not that viewers can watch it. Read status.uploadStatus on the video resource (Videos resource): uploaded while it processes, then processed, or failed or rejected with a failureReason or rejectionReason (a codec problem, a duplicate, a claim, and so on). Ask for the processingDetails part to see processing progress. Polling with videos.list costs 1 unit from the shared bucket per call, so space the polls out.

Uploading to YouTube from TypeScript with Social SDK

Social SDK is an open-source TypeScript library whose YouTube adapter runs the resumable protocol above inside one posts.publish call. You pass the video, a title, a visibility, and the made-for-kids declaration (the adapter requires both explicitly). It starts the session, hands the session to your saveUploadSession callback before sending any bytes, uploads in 8 MiB chunks, and maps the video's uploadStatus to a typed outcome: processing for uploaded, published with the watch URL for processed, and failed for failed or rejected. An upload that stops partway comes back as unknown, so you can query the saved session and resume rather than start a second video.

npm install @opencoredev/social-sdk
import { chmod, readFile, writeFile } from "node:fs/promises";
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { youtube } from "@opencoredev/social-sdk/youtube";

const accessToken = process.env["YOUTUBE_ACCESS_TOKEN"]; // OAuth token with the youtube.upload scope
const channelId = process.env["YOUTUBE_CHANNEL_ID"];
if (!accessToken || !channelId) throw new Error("Set YOUTUBE_ACCESS_TOKEN and YOUTUBE_CHANNEL_ID");

const social = createSocial({
  backend: youtube({
    auth: { accessToken, channelId },
    // The resumable session URL is a secret. Save it before any bytes are sent,
    // so an interrupted upload can be resumed instead of creating a second video.
    saveUploadSession: async (session) => {
      await writeFile("./upload-session.json", JSON.stringify(session), { mode: 0o600 });
      await chmod("./upload-session.json", 0o600); // mode only applies when the file is new
    },
  }),
});
const account = connectedAccountRef({ backend: "default", platform: "youtube", accountId: channelId });

const video = new Blob([await readFile("./video.mp4")], { type: "video/mp4" });

const result = await social.posts.publish({
  targets: [{ account, options: { title: "Release notes", visibility: "private", madeForKids: false } }],
  content: {
    text: "What changed this week.",
    media: [
      {
        kind: "video",
        source: { kind: "blob", blob: video, fingerprint: "release-notes-v1" },
        mimeType: "video/mp4",
        byteSize: video.size,
      },
    ],
  },
});

for (const outcome of result.outcomes) {
  if (outcome.state === "published") console.log("published", outcome.url);
  else if (outcome.state === "processing") console.log("uploaded, YouTube is still processing it");
  else console.log(outcome.state, outcome);
}

We type-checked this example against @opencoredev/social-sdk 0.5.0 from npm and ran it on 1 November 2026 with Node.js 24 and Bun, using a stub fetch in place of Google and a 20 MB test file. It sent one POST with uploadType=resumable, a private status and selfDeclaredMadeForKids: false, saved the session file with mode 0600 before any bytes went out, then sent three PUT chunks. It printed the processing line when the stub answered uploaded and the watch URL when it answered processed. In 0.5.0 the adapter reads the video from a Blob or a replayable stream (it doesn't fetch remote URLs), caps titles at 100 characters and descriptions at 5,000 UTF-8 bytes, rejects < and > in titles, and doesn't set tags or a category. Pass schedule: { at: "2026-12-01T15:00:00Z" } to posts.publish, next to targets, and it uploads the video private with that publishAt time.

The adapter takes an existing access token and doesn't refresh it. The SDK's youtubeOAuth server helper handles Google's authorization code flow and channel discovery (authentication guide). Resuming a saved session, thumbnails, captions, playlists, comments, and analytics are on the YouTube platform page. To build without a Google project at all, start with the mock quickstart. The same client can post to X, Bluesky, LinkedIn, and the other supported platforms in one call.

Common questions

How many videos can I upload a day with the YouTube API?
100 by default. Since June 2026 videos.insert has its own quota bucket of 100 calls a day, each costing 1 unit. You can request more through Google's Quota Extension Form.
Does a video upload still cost 1,600 quota units?
No. Google cut it to about 100 units in December 2025, then moved uploads to their own bucket in June 2026, where each upload costs 1 unit of a 100-a-day allowance.
Can I upload with an API key or a service account?
Not for an ordinary channel. Uploading needs the channel owner's OAuth 2.0 authorization, ideally with only the youtube.upload scope. Service accounts work only for YouTube content owners who manage many channels.
How do I upload a YouTube Short with the API?
With the same videos.insert call. The request has no Shorts field, so a Short goes up like any other video.
Can I upload a video from a URL?
Not directly. The API takes the file's bytes, so your server downloads the video first and then uploads it.
Can I schedule a YouTube video through the API?
Yes. Upload it with privacyStatus set to private and a future publishAt time.

Sources

Google's YouTube Data API and identity pages, read on 1 November 2026, with the update date each page showed: