How to Upload Videos with the YouTube Data API (2026)
A guide to YouTube Data API uploads in 2026, with OAuth setup, daily quotas, Node.js and Python examples, scheduling, Shorts, and common errors.
The YouTube Data API lets you upload videos from your own app using videos.insert. The channel owner grants access through OAuth, and your app sends the video’s metadata and file to YouTube through a resumable upload session.
Before launch, you need to configure the app for ongoing access and get approval to publish public videos. Refresh tokens expire after seven days while an external OAuth app is in Testing status. YouTube also restricts uploads from unaudited projects to private visibility. You might finish a test upload without noticing either restriction, then find that you can’t make the video public or upload again the following week.
Quota planning has changed, too. In 2026, the default allocation includes 100 upload calls per day in a separate quota bucket. Older examples that budget 1,600 units per upload use a model that no longer applies.
API keys and OAuth serve different purposes
An API key identifies your Google Cloud project and gives you access to public data. You can use it to fetch video metadata with videos.list, look up channels with channels.list, or search with search.list. It doesn’t authorize your app to upload to someone’s channel.
For uploads, you need OAuth 2.0 and the https://www.googleapis.com/auth/youtube.upload scope. The channel owner signs in through Google’s consent screen and grants your app permission. Your app then stores a refresh token and exchanges it for access tokens, which last about an hour. Those access tokens authorize the upload requests.
Service accounts aren’t an option for this flow. Google’s authentication guide explains that the YouTube Data API does not support them and can return NoLinkedYouTubeAccount if you try. The channel owner must grant access before your app can upload on their behalf.
Set up your Google Cloud project
You create both types of credentials in Google Cloud Console. For a web app that uploads videos:
- Create a project or select an existing one in Google Cloud Console.
- Open APIs & Services → Library, search for YouTube Data API v3, and enable it.
- Open APIs & Services → OAuth consent screen. Add your app name, support email, and requested scopes. During development, add the test users who can grant access.
- Open Credentials → Create Credentials and choose OAuth client ID. Select Web application and add your redirect URI. If you also need an API key for public reads, you can create one in the same project.
Restrict any API key you create to the YouTube Data API and to your server’s IP addresses or your site’s referrers. If an unrestricted key leaks, someone else can use your project’s quota.
Move OAuth out of Testing before launch
An external OAuth app in Testing status supports up to 100 listed test users. Its refresh tokens expire after seven days when you request YouTube upload access. Google’s OAuth documentation describes an exception for basic profile scopes, but that exception doesn’t cover uploads.
Leaving the app in Testing means users will need to reconnect their channels each week. Before launch, publish the OAuth app and complete OAuth app verification for the YouTube scopes you request. Unverified apps show users a warning during sign-in.
For verification, prepare a privacy policy on your verified domain, a reason for each requested scope, and a demo video showing the consent flow and how your app uses the data. This review covers OAuth access. YouTube’s compliance audit is a separate review that determines whether your project can upload public and unlisted videos.
Plan for the current quota limits
Google’s getting started guide lists three default daily allocations: 100 videos.insert calls, 100 search.list calls, and 10,000 units shared by all other endpoints. The quota cost reference puts each upload at one unit in the Video Uploads bucket.
Under the older model, an upload cost 1,600 units from a shared pool of 10,000. That allowed about six uploads a day, with little quota left for other requests. Uploads and searches now have separate allocations, so they no longer use that general pool.
| Method | Cost | Bucket |
|---|---|---|
videos.insert |
1 | Video Uploads (100 per day) |
search.list |
1 | Search Queries (100 per day) |
videos.list, channels.list, commentThreads.list |
1 | General (10,000 units per day) |
videos.update, videos.delete |
50 | General |
playlists.insert, playlistItems.insert |
50 | General |
thumbnails.set |
50 | General |
comments.insert |
50 | General |
captions.insert |
400 | General |
Quotas reset at midnight Pacific Time. Exceeding a bucket returns HTTP 403 with the reason quotaExceeded; wait for the reset before retrying. To request more than 100 uploads a day, submit the Audit and Quota Extension Form. Google does not publish a turnaround time, so allow for the review in your launch plans.
The work around an upload still uses general quota. Setting a thumbnail costs 50 units, and adding the video to a playlist costs another 50. A pipeline that does both spends one upload call plus 100 general units per video, before any requests to read the video’s metadata.
Complete YouTube’s audit to publish public videos
You can send privacyStatus: public and still end up with a private video. As Google’s audit guide explains, projects created after July 28, 2020 must complete YouTube’s compliance audit before they can upload public or unlisted videos.
The audit checks your use of the API against YouTube’s API Services Terms of Service. That includes the Developer Policies requirement to refresh or delete stored authorized data within 30 calendar days.
Use the same form as the quota extension request. Describe what your app does and include a screencast of the consent flow and an upload. The reviewer should be able to follow that flow in the app you submit. You can continue developing with private uploads while you wait for approval.
Upload a video with videos.insert
A resumable upload separates the video’s metadata from the file transfer. You first create a session with a JSON request, then send the video bytes to the URL YouTube returns. If the transfer fails partway through, you can ask how much YouTube received and continue from that point.
Google’s resumable upload protocol defines the sequence:
POST https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,statuswith headersX-Upload-Content-Length(the file size) andX-Upload-Content-Type(for examplevideo/mp4), and the metadata as the JSON body.- Read the
Locationheader from the200response. That is the session URI. PUTthe bytes to the session URI, either in one request or in chunks withContent-Range: bytes start-end/total. Use equal-sized chunks in multiples of 256 KB, except for the final chunk.- If a chunk fails, send an empty
PUTwithContent-Range: bytes */total; a308 Resume Incompleteresponse carries aRangeheader telling you the last byte received, and you continue from the next one. Use exponential backoff on 500, 502, 503, and 504. - The final
PUTreturns200or201with the video resource, including itsid.
Set the title, category, and publishing options in the initial metadata request. The videos resource reference documents these fields and their limits:
| Field | Rule |
|---|---|
snippet.title |
Required; up to 100 characters; < and > not allowed |
snippet.description |
Up to 5,000 bytes; < and > not allowed; 00:00 timestamps create chapters |
snippet.tags[] |
500 characters total including separating commas; tags with spaces are quoted and the quotes count |
snippet.categoryId |
Required on insert; 22 is People & Blogs, 28 is Science & Technology |
status.privacyStatus |
public, unlisted, or private |
status.publishAt |
Scheduled publish time; requires privacyStatus to be private |
status.selfDeclaredMadeForKids |
Declares whether the video is made for kids; set it to match the content |
notifySubscribers (query param) |
Default true; set false for bulk or test uploads |
This Node.js example starts a resumable session and sends the entire file in a single request. The file argument holds the video bytes in memory; the function returns the video ID after the upload succeeds.
const UPLOAD = 'https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status'
export async function uploadVideo({ token, file, contentType, title, description, tags, categoryId, privacyStatus }) {
// 1. Start a resumable session with the metadata
const init = await fetch(UPLOAD + '¬ifySubscribers=false', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json; charset=UTF-8',
'X-Upload-Content-Length': String(file.byteLength),
'X-Upload-Content-Type': contentType,
},
body: JSON.stringify({
snippet: { title, description, tags, categoryId },
status: { privacyStatus, selfDeclaredMadeForKids: false },
}),
})
if (!init.ok) throw new Error(`session init failed: ${init.status} ${await init.text()}`)
const sessionUri = init.headers.get('location')
// 2. Send the bytes (single request; chunk for files over a few hundred MB)
const upload = await fetch(sessionUri, {
method: 'PUT',
headers: { 'Content-Type': contentType, 'Content-Length': String(file.byteLength) },
body: file,
})
if (!upload.ok) throw new Error(`upload failed: ${upload.status} ${await upload.text()}`)
const video = await upload.json()
return video.id
}
For larger files, you can read and upload one chunk at a time. This Python example uses requests to send 8 MiB chunks and query the session after a server error:
import os
import time
import requests
UPLOAD = 'https://www.googleapis.com/upload/youtube/v3/videos'
CHUNK = 8 * 1024 * 1024 # 8 MiB, a multiple of 256 KiB
def upload_video(token: str, path: str, metadata: dict, content_type: str = 'video/mp4') -> str:
size = os.path.getsize(path)
init = requests.post(
UPLOAD,
params={'uploadType': 'resumable', 'part': 'snippet,status', 'notifySubscribers': 'false'},
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json; charset=UTF-8',
'X-Upload-Content-Length': str(size),
'X-Upload-Content-Type': content_type,
},
json=metadata,
timeout=30,
)
init.raise_for_status()
session = init.headers['Location']
sent = 0
with open(path, 'rb') as f:
while sent < size:
f.seek(sent)
chunk = f.read(CHUNK)
end = sent + len(chunk) - 1
for attempt in range(5):
r = requests.put(
session,
headers={'Content-Type': content_type, 'Content-Range': f'bytes {sent}-{end}/{size}'},
data=chunk,
timeout=300,
)
if r.status_code in (200, 201):
return r.json()['id']
if r.status_code == 308: # chunk accepted, more to come
sent = int(r.headers.get('Range', 'bytes=0--1').split('-')[-1]) + 1
break
if r.status_code in (500, 502, 503, 504): # ask where to resume, then retry
time.sleep(2 ** attempt)
probe = requests.put(session, headers={'Content-Range': f'bytes */{size}'}, timeout=30)
if probe.status_code == 308 and 'Range' in probe.headers:
sent = int(probe.headers['Range'].split('-')[-1]) + 1
continue
r.raise_for_status()
else:
raise RuntimeError('upload gave up after 5 retries')
raise RuntimeError('upload ended without a video id')
Upload sessions expire, and requests to an expired session return 404. Google does not publish the session lifetime. If yours has expired, create a new session and restart the upload.
Scheduling, Shorts, and formats
To schedule a video, set status.publishAt to an RFC 3339 timestamp and keep privacyStatus set to private. YouTube makes the video public at the scheduled time. Sending publishAt with a public privacy status returns invalidPublishAt.
YouTube Shorts use the same upload endpoint. YouTube classifies vertical or square videos of up to three minutes as Shorts, following the increase from 60 seconds in October 2024. You don’t need to add #Shorts to the title or description.
YouTube accepts MP4, MOV, MPEG-2, MPEG-4, AVI, WMV, and FLV, among other formats, including WebM and ProRes. For a pipeline that transcodes video before uploading, H.264 video with AAC audio in an MP4 file follows YouTube’s encoding recommendations.
The upload limit is 256 GB or 12 hours, whichever you reach first. Channels without phone verification have a 15-minute limit, so check the channel’s verification status before testing with a longer video.
Troubleshoot upload errors
Log the errors[0].reason field along with the HTTP status. A 403 response can mean you’ve exhausted your quota or that the token lacks permission; the reason tells you which problem to investigate. The videos.insert reference documents upload errors, including the following:
| Reason | HTTP | Meaning | Fix |
|---|---|---|---|
quotaExceeded |
403 | A quota bucket is empty until midnight PT | Stop; request an extension if it recurs |
uploadLimitExceeded |
400 | Channel cannot upload more or longer videos | Verify the channel; check the 15-minute cap |
youtubeSignupRequired |
401 | The Google account has no YouTube channel | The user must create a channel first |
forbidden / insufficientPermissions |
403 | Token lacks the upload scope or access to the channel | Request consent with the right scope and account |
forbiddenPrivacySetting |
403 | Requested visibility is unavailable; unaudited projects are restricted to private uploads | Upload as private or complete the audit |
invalidTitle / invalidDescription / invalidTags |
400 | Metadata exceeds a length limit or contains unsupported characters | Validate before upload |
invalidCategoryId |
400 | Category not valid for the region | Use videoCategories.list |
invalidPublishAt |
400 | Scheduled time with a non-private status | Set privacyStatus: private |
mediaBodyRequired |
400 | Metadata sent without a video body | Send the bytes to the session URI |
invalidVideoMetadata |
400 | Malformed request body | Check part matches the fields sent |
NoLinkedYouTubeAccount |
401 | Service account or account without a channel | Use user OAuth |
Is the YouTube API free?
YouTube doesn’t charge for Data API requests or offer a paid tier. You need to stay within your project’s quota or apply for an extension through the audit process.
For an app that publishes to several networks, account for those differences in your costs and launch schedule. X bills per request, while YouTube requires time for quota planning and reviews. Our comparison of social media APIs covers the other platforms.
Uploading through PostZen
Maintaining a direct YouTube integration means managing token refreshes, upload retries, and quota alongside the review process. If your app also publishes to TikTok or Instagram, PostZen provides a shared API for those uploads.
PostZen’s audited YouTube integration handles OAuth, token refreshes, and resumable uploads with retries. It also handles scheduling within the available quota. After connecting a channel, send the video URL and YouTube settings to the posts endpoint:
curl -X POST https://api.postzen.dev/v1/posts \
-H "Authorization: Bearer pzn_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Building the release pipeline in public",
"content": "How we ship every Friday. Chapters:\n00:00 Intro\n02:10 The pipeline",
"scheduledFor": "2026-09-12T15:00:00Z",
"mediaItems": [{ "url": "https://cdn.example.com/release-pipeline.mp4" }],
"platforms": [
{
"platform": "youtube",
"accountId": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e",
"settings": {
"privacyStatus": "public",
"tags": ["devops", "build in public"],
"categoryId": "28",
"madeForKids": false,
"notifySubscribers": true
}
}
]
}'
Include privacyStatus in the request and keep the title within YouTube’s 100-character limit. PostZen accepts videos up to 2 GB and does not yet support thumbnails, captions, or playlists. YouTube’s daily upload quota and channel limits still apply, and the integration remains subject to YouTube’s audit requirements.
The YouTube integration docs cover the request fields. For supported publishing features, see the YouTube page or the social media API overview.
Frequently asked questions
How do I get a YouTube API key?
Create a project in Google Cloud Console, enable YouTube Data API v3, then open Credentials and create an API key. Use the key for public data requests. Uploads require an OAuth 2.0 client ID and the channel owner’s consent to the youtube.upload scope.
Is the YouTube Data API free?
Yes. Google does not charge for requests. The default daily quota allows 100 upload calls, 100 search calls, and 10,000 units shared by other endpoints. Quotas reset at midnight Pacific Time. You can request a larger allocation through the audit and quota extension process.
How many videos can I upload per day with the YouTube API?
The default quota allows 100 videos.insert calls per day in a dedicated upload bucket. Uploads no longer cost 1,600 units from the general pool, as they did under the older quota model.
Why is my YouTube API upload private?
YouTube restricts uploads from projects created after July 28, 2020 to private visibility until they pass its API compliance audit. Submit the Audit and Quota Extension Form to request approval for public and unlisted uploads.
Can I upload YouTube Shorts through the API?
Yes. Upload a vertical or square video of up to three minutes with videos.insert. YouTube classifies it as a Short; no separate endpoint is needed.
Why do my YouTube refresh tokens stop working after a week?
For an external OAuth app in Testing status, refresh tokens with YouTube upload access expire after seven days. Testing also limits access to 100 listed users. Publish the OAuth app and complete verification before using it in production.



