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:
| Part | Writable fields |
|---|---|
snippet | title, description, tags[], categoryId, defaultLanguage |
status | privacyStatus, publishAt, selfDeclaredMadeForKids, containsSyntheticMedia, embeddable, license, publicStatsViewable |
| Other | localizations, 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:
| Scope | What else it allows |
|---|---|
youtube.upload | Nothing else: upload only. Ask for this one if uploading is all you do. |
youtube | Full management of the channel |
youtube.force-ssl | Full management, including comments and captions |
youtubepartner | YouTube 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):
| Bucket | Default per day |
|---|---|
videos.insert | 100 calls, 1 unit each |
search.list | 100 calls, 1 unit each |
| Every other method | 10,000 units combined |
videos.batchGetStats | 10,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.inserthas 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.uploadscope. 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.insertcall. 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
privacyStatusset toprivateand a futurepublishAttime.
Sources
Google's YouTube Data API and identity pages, read on 1 November 2026, with the update date each page showed:
- Videos: insert (updated 15 October 2026): endpoint, file limits, scopes, writable fields, quota, default privacy
- Quota costs (updated 15 October 2026): default allocations, reset time, per-method costs
- Resumable uploads (updated 15 October 2026): session, status check, resume, chunk rules
- Upload a video (updated 15 October 2026):
youtube.uploadscope, client library sample - Videos resource (updated 15 October 2026):
uploadStatus,processingDetails - Revision history (updated 15 October 2026): granular quota (1 June 2026), videos.batchGetStats bucket (3 June 2026), upload cost change (4 December 2025), 2020 private-upload note
- OAuth 2.0 for web server apps (updated 30 October 2026): offline access
- Implementing OAuth 2.0 authorization (updated 15 October 2026): service accounts only for content owners
- Using OAuth 2.0 to access Google APIs (updated 29 October 2026): 7-day refresh tokens in Testing