How to Upload Videos with the YouTube Data API in 2026 (the Quota Math Just Changed)
Yes: call videos.insert with the https://www.googleapis.com/auth/youtube.upload OAuth scope, using the resumable upload protocol. It does not force a privacy status: if you omit status.privacyStatus the video defaults to public (some uploads default to private based on creator account settings), so set it explicitly. Google's current reference says unverified API projects are not restricted to private, but its 2020 revision history says projects created after July 28, 2020 are locked to private until audited, so read status.privacyStatus back from the response instead of assuming.
Last reviewed: October 18, 2026. Checked against Google's videos.insert reference, resumable upload guide and revision history on that date.
For most of the API's life, that one call was also the fastest way to burn through your daily quota: 1,600 units against a 10,000-unit default, roughly six uploads a day. Google's revision history records two changes since: the cost fell to about 100 units on December 4, 2025, and on June 1, 2026 videos.insert moved into its own quota bucket. The current reference lists it at 1 unit in a Video Uploads bucket with a default of 100 calls per day.
What you need before you write a single line of code
- A Google Cloud project with the YouTube Data API v3 enabled.
- An OAuth 2.0 client (web or installed app type, depending on where the upload runs).
- Consent from the channel owner for the
https://www.googleapis.com/auth/youtube.uploadscope, or the broaderyoutube.force-sslscope if you also need to manage captions, playlists, or comments. - A verified project if you want uploads that are visible to anyone other than the channel owner (more on this below).
- Patience for resumable upload logic. Video files are large, mobile connections drop, and the naive single-request upload will fail you eventually.
There is no app-only or server-to-server auth path here. Every upload happens on behalf of an authenticated YouTube channel through OAuth2. There is no service-account shortcut, no API-key-only mode. If your product uploads on behalf of users, each user goes through the consent screen once and you store a refresh token.
The videos.insert call: multipart and resumable
videos.insert takes two things: a metadata object (title, description, tags, category, privacy status) and the actual video bytes. You have two ways to send both.
Multipart upload sends metadata and video in a single request. It works, but for anything over a few dozen megabytes it is fragile. One dropped connection and you start the whole upload over.
Resumable upload is the one to actually build with. It is a two-step handshake:
POST https://www.googleapis.com/upload/youtube/v3/videos
?uploadType=resumable&part=snippet,status
Content-Type: application/json; charset=UTF-8
X-Upload-Content-Type: video/*
X-Upload-Content-Length: <bytes>
{ "snippet": {...}, "status": {...} }
The response includes a Location header, a session URI you then PUT the video bytes to, in chunks, with the ability to query progress and resume from the last received byte if the connection drops. For anything you expect to run unattended (a scheduler, a batch job, a CI pipeline that publishes demo videos), resumable is the only sane choice.
Working sample: OAuth scope and a resumable upload
First get a token. Send the channel owner to Google's consent screen with the upload scope, and ask for offline access so you receive a refresh token:
https://accounts.google.com/o/oauth2/v2/auth
?client_id={client_id}
&redirect_uri={redirect_uri}
&response_type=code
&scope=https://www.googleapis.com/auth/youtube.upload
&access_type=offline
&prompt=consent
Exchange the returned code at https://oauth2.googleapis.com/token with grant_type=authorization_code, then put the access_token in YOUTUBE_ACCESS_TOKEN. This script uploads a file with the resumable protocol. If the connection drops or the server returns 500, 502, 503 or 504, it asks the session how many bytes it holds (an empty PUT with Content-Range: bytes */{total}), then resumes from the next byte, with exponential backoff. It requests private and prints the privacy status YouTube actually applied.
// youtube-upload.mjs (Node 22+, no dependencies)
// YOUTUBE_ACCESS_TOKEN=... node youtube-upload.mjs ./video.mp4 "Title"
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { setTimeout as sleep } from "node:timers/promises";
const token = process.env.YOUTUBE_ACCESS_TOKEN; // needs the youtube.upload scope
const [file, title] = process.argv.slice(2);
if (!token || !file || !title) {
console.error("Usage: YOUTUBE_ACCESS_TOKEN=... node youtube-upload.mjs <file> <title>");
process.exit(1);
}
const { size } = await stat(file);
const auth = { authorization: `Bearer ${token}` };
// 1. Start a resumable session. The Location header is the session URI.
const start = await fetch(
"https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status",
{
method: "POST",
headers: {
...auth,
"content-type": "application/json; charset=UTF-8",
"x-upload-content-length": String(size),
"x-upload-content-type": "video/mp4",
},
body: JSON.stringify({
snippet: { title, description: "Uploaded with the YouTube Data API.", categoryId: "22" },
status: { privacyStatus: "private", selfDeclaredMadeForKids: false },
}),
},
);
if (!start.ok) throw new Error(`Session start failed: HTTP ${start.status} ${await start.text()}`);
const session = start.headers.get("location");
// 2. Send the bytes from `offset` to the end. 308 means "incomplete".
async function put(offset) {
const body = Readable.toWeb(createReadStream(file, { start: offset }));
return fetch(session, {
method: "PUT",
headers: {
...auth,
"content-type": "video/mp4",
"content-length": String(size - offset),
...(offset ? { "content-range": `bytes ${offset}-${size - 1}/${size}` } : {}),
},
body,
duplex: "half",
});
}
// 3. Ask the session how many bytes it already holds (empty PUT).
async function receivedBytes() {
const res = await fetch(session, {
method: "PUT",
headers: { ...auth, "content-length": "0", "content-range": `bytes */${size}` },
});
if (res.status === 200 || res.status === 201) return { done: res };
if (res.status !== 308) throw new Error(`Status check failed: HTTP ${res.status}`);
const range = res.headers.get("range"); // e.g. "bytes=0-999999"; absent = nothing yet
return { offset: range ? Number(range.split("-")[1]) + 1 : 0 };
}
let res;
let offset = 0;
for (let attempt = 0; attempt < 6; attempt++) {
try {
res = await put(offset);
if (res.status === 200 || res.status === 201) break;
if (![500, 502, 503, 504].includes(res.status)) {
throw new Error(`Upload failed: HTTP ${res.status} ${await res.text()}`);
}
} catch (err) {
if (err.message.startsWith("Upload failed")) throw err; // permanent failure
}
await sleep(2 ** attempt * 1000); // exponential backoff, then resume
const state = await receivedBytes();
if (state.done) { res = state.done; break; }
offset = state.offset;
}
if (!res || ![200, 201].includes(res.status)) throw new Error("Upload did not complete.");
const video = await res.json();
// Always read the privacy status back: do not assume what you requested was applied.
console.log(`Video ${video.id} uploaded, privacyStatus=${video.status.privacyStatus}`);
Run it as YOUTUBE_ACCESS_TOKEN=... node youtube-upload.mjs ./video.mp4 "My title".
Syntax-checked with node --check and run against a local mock of the two endpoints that cut the connection at about 40% of a 1 MB file. The mock confirmed the session-start headers and body, the zero-length status check, and the resumed Content-Range. It has not been run against YouTube itself, which needs your credentials and spends quota, so do a first run with a short private test video. The script sends the whole remaining file in one request per attempt, which is the approach Google recommends; chunked uploads are only worth it for progress tracking on unstable networks.
To schedule a publish time on YouTube's side, keep privacyStatus as private and add "publishAt": "2026-11-02T15:00:00Z" (an ISO 8601 UTC time) to the status object. That field only works on a private video that has never been published.
Schedule your first post free with TimeToPost and put these strategies to work.
Quota math: old vs new, worked out
This is the part that actually changes your architecture, so let's do the numbers rather than wave at them.
Every Google Cloud project gets a default daily quota that resets at midnight Pacific time regardless of your local timezone. Until 2026 every call drew from one shared pool of 10,000 units, and videos.insert was priced far above reads because YouTube treats an upload as the most expensive thing you can ask the API to do.
| Period | Cost of one videos.insert | Pool it draws from | Uploads in a day |
|---|---|---|---|
| Before Dec 4, 2025 | About 1,600 units | Shared 10,000-unit pool | About 6, using 9,600 of 10,000 units |
| Dec 4, 2025 | About 100 units (documentation and Quota Calculator updated) | Shared 10,000-unit pool | About 100 in theory, competing with every other call |
| From June 1, 2026 | 1 unit in the Video Uploads bucket (per the current reference) | Its own bucket, default 100 calls per day | 100 by default, without eating the 10,000 units your reads and updates use |
Sources: the revision history entries for December 4, 2025 and June 1, 2026, and the quota cost page, which states the defaults of 100 search.list calls, 100 videos.insert calls and 10,000 units for everything else. Check your own Google Cloud Console quota page before you rely on a number, since your project may have a different allocation.
Either way, requesting a quota increase from Google is still the move if your product uploads video at any real volume. The application asks what you are building and why; approval can take days to a few weeks.
Does videos.insert force a privacy status?
Not by itself, but two things can end up with a private video, so check both.
What you send decides it. The reference says that if you do not specify status.privacyStatus, the uploaded video defaults to public, and that certain uploads might default to private based on creator account settings. So set privacyStatus explicitly to private, unlisted or public on every call. Upload as private and publish later if you want a review step.
The unverified-project rule is documented inconsistently. Google's revision history for July 28, 2020 says videos uploaded through videos.insert from unverified API projects created after that date are restricted to private viewing mode until the project passes an audit, and that creators get an email saying the video is locked as private. The current videos.insert reference, read on October 18, 2026, says "Videos uploaded from unverified API projects are not restricted to private viewing mode." Both statements are on Google's site today.
The safe approach is to test with your own project: upload one video with privacyStatus: "public" to a throwaway channel and read status.privacyStatus in the response, as the script below prints. If it comes back private when you asked for public, your project is being treated as unverified, and the fix is submitting it for the API Services compliance audit. Budget calendar time for that before you promise customers automated publishing.
Required metadata and the 15-minute rule
The reference marks only the part parameter as required, but send these three every time:
snippet.title, even if you plan to edit it later.snippet.categoryId, a numeric YouTube category ID. Pull valid IDs fromvideoCategories.listrather than guessing.status.privacyStatus, one ofprivate,unlisted, orpublic, so you never depend on the default.
You can also set status.selfDeclaredMadeForKids, which the sample below does.
One constraint trips up anyone uploading longer content: an account has to be phone-verified to upload videos longer than 15 minutes. Verification is a one-time step in YouTube Studio, but if your product onboards users programmatically, tell them up front, or you will get support tickets about uploads that silently truncate or fail for accounts stuck at the 15-minute ceiling.
Errors you will actually hit
quotaExceeded: you have used your daily allowance. Check the Cloud Console quota dashboard before assuming it is a bug, and remember the reset happens at midnight Pacific, not your local midnight.uploadLimitExceeded: separate from API quota, this is YouTube's per-channel daily upload cap, which exists independently of how much of your project's quota you have left.invalidCategoryId: a stale or wrong category ID. Pull the current list fromvideoCategories.listrather than hardcoding one.- Resumable session expiry: session URIs from the initial POST are not valid forever. If your batch job queues uploads and processes them later, do not cache a session URI across days.
Scheduling uploads instead of publishing immediately
YouTube can hold an uploaded video for later publication through status.publishAt. The video must be private and must never have been published; unlisted is not a valid privacy setting for that scheduling field. Set a future timestamp and follow the official video status requirements. This upload-first approach is distinct from a scheduler that waits until the chosen time before uploading.
TimeToPost's YouTube connector holds the job until its scheduled time and then uploads the video with the selected privacy setting. A real scheduled private upload has passed, including processing and channel verification. Check Settings โ Integrations for your workspace's access. A successful private test must not be presented as proof of unrestricted public uploads.
When the API is not worth it
The upload call is short. The cost is everything around it: a Google Cloud project, an OAuth consent screen that may need review, token storage and refresh, resumable-upload handling, a default of 100 uploads a day, and a compliance audit before you can ask for more quota or be sure your uploads are not locked to private. If you upload a few videos a week to your own channel, YouTube Studio's native scheduling does the same job with none of that.
The API earns its keep when uploading is part of a product you are building or a pipeline that produces video on its own. If you only want scheduled uploads alongside your other networks, TimeToPost has a YouTube connector that holds the video until its scheduled time and then uploads it with the privacy setting you chose. It is not open to every workspace yet: a private scheduled upload has passed testing, but Google's OAuth review and API audit for the product are still separate steps. Check Settings โ Integrations for what your workspace can connect.
FAQ
Do I need a Google Cloud quota increase to upload more than a handful of videos a day?
Probably, if you are uploading at any real volume. The default for videos.insert is now 100 calls per day in its own quota bucket, so a busy product can still hit that ceiling. Increases go through the Quota Extension Form, which requires a completed compliance audit.
Why are my uploaded videos stuck as private even though I requested public?
The most likely cause is that your Google Cloud project has not passed YouTube's API compliance audit. Google's 2020 revision history says unverified projects created after July 28, 2020 are locked to private, although the current videos.insert reference says otherwise. Read status.privacyStatus from the upload response; if it is private after you asked for public, submit the project for audit. Also check the channel's own default upload settings.
Can I upload without OAuth, using just an API key?
No. Uploads require an authenticated user via OAuth2 with the youtube.upload scope. API keys alone only work for public read-only endpoints.
What happens if my upload gets interrupted halfway through? If you used the resumable upload protocol, you query the session URI for the last received byte and resume from there. If you used simple multipart upload, you start over, which is the main reason to build on resumable from day one.
Do I really need phone verification just to post a 20-minute video? Yes. Any video over 15 minutes requires the uploading account to have completed phone verification in YouTube Studio, a one-time step that is worth doing before you build anything long-form into your workflow.
If your uploads are landing on YouTube Shorts specifically, the constraints are different again, worth checking against our YouTube Shorts scheduling guide before you script a batch job around this endpoint. If you are building the same kind of pipeline for TikTok, our TikTok API guide covers the equivalent upload flow on that side, and our YouTube best-time data is worth pairing with publishAt so scheduled videos land when your channel's audience is actually online. Our posting schedule generator can turn that data into an actual calendar.