Use the YouTube Data API: authorize once with the youtube.upload OAuth scope, keep the refresh token, and call videos.insert from a script with the video file, a title and a privacy status. Any square or vertical video up to three minutes long is treated as a Short, so no special flag is needed. Two things trip up a new project: advice, still common, about a private-until-audit lock that Google’s upload reference no longer states, and the daily upload bucket. Both are covered below.

The other route, driving YouTube Studio in a real browser, avoids the API’s quota and tokens and trades them for fragility and a Terms of Service question. The last third of this page compares the two honestly so you can pick.

Route 1: the YouTube Data API, step by step

Everything here is the documented path, using Google’s official Node client library, googleapis (Apache-2.0).

Step 1: create a Cloud project and enable the API

In the Google Cloud console, create a project and enable YouTube Data API v3 for it. Quota and the audit status discussed below both belong to this project, not to your channel — every channel you upload to through it shares one allowance.

Uploading always needs a user’s permission; an API key is not enough. The videos.insert reference accepts any of four scopes, and the narrowest is https://www.googleapis.com/auth/youtube.upload — the reference’s own Ruby sample describes it as “A limited OAuth 2 access scope that allows for uploading files, but not other types of account access.” That is the one to ask for: a leaked upload token is far less dangerous than a full-account one.

Configure the OAuth consent screen (user type External unless you are inside a Google Workspace organisation), add that scope, and add your own Google account as a test user. Then create an OAuth client of type Desktop app and note its client ID and secret. Desktop clients may redirect to a loopback address; Google’s installed-app guide says to use http://127.0.0.1:port and “start an HTTP listener on a random available port”.

Step 3: get a refresh token, once per channel

An access token expires quickly. A refresh token is what lets an unattended script keep uploading, and you only get one if you ask for offline access. This script opens a throwaway listener on a random loopback port, prints the consent URL, catches the redirect and prints the refresh token:

// get-token.mjs — run once for each channel you will upload to.
// Setup: npm install googleapis; export CLIENT_ID and CLIENT_SECRET from your Desktop client.
import http from 'node:http';
import { google } from 'googleapis';

const { CLIENT_ID, CLIENT_SECRET } = process.env;

const server = http.createServer();
server.listen(0, '127.0.0.1'); // port 0 = let the OS pick a free port
await new Promise((resolve) => server.once('listening', resolve));
const redirectUri = `http://127.0.0.1:${server.address().port}`;

const oauth2 = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, redirectUri);
const url = oauth2.generateAuthUrl({
  access_type: 'offline', // without this there is no refresh token
  prompt: 'consent', // re-issue a refresh token even if you authorized before
  scope: ['https://www.googleapis.com/auth/youtube.upload'],
});
console.log('Open this URL, sign in and choose the channel:\n' + url);

const code = await new Promise((resolve) => {
  server.on('request', (req, res) => {
    const found = new URL(req.url, redirectUri).searchParams.get('code');
    res.end(found ? 'Authorized. You can close this tab.' : 'No code in this request.');
    if (found) resolve(found);
  });
});
server.close();

const { tokens } = await oauth2.getToken(code);
console.log('REFRESH_TOKEN=' + tokens.refresh_token);

A refresh token belongs to the identity that granted it, so for several channels run the flow once per channel and store one token each. Google’s OAuth 2.0 overview sets a limit of 100 refresh tokens per Google Account per OAuth client ID; past that, “creating a new refresh token automatically invalidates the oldest refresh token without warning”. Re-running this script dozens of times while debugging can therefore silently kill a token a production job depends on.

Step 4: upload with videos.insert

With a refresh token in the environment, an upload is one call. The client library exchanges the refresh token for access tokens by itself:

// upload-short.mjs — usage: node upload-short.mjs short.mp4 [publishAt]
// Env: CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN (from get-token.mjs).
import fs from 'node:fs';
import { google } from 'googleapis';

const { CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN } = process.env;
const [file, publishAt] = process.argv.slice(2);

const auth = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET);
auth.setCredentials({ refresh_token: REFRESH_TOKEN });
const youtube = google.youtube({ version: 'v3', auth });

const res = await youtube.videos.insert({
  part: ['snippet', 'status'],
  requestBody: {
    snippet: {
      title: 'How tides actually work #Shorts',
      description: 'Uploaded with the YouTube Data API.',
    },
    status: {
      // A scheduled video must be uploaded as private; YouTube flips it at publishAt.
      privacyStatus: publishAt ? 'private' : 'public',
      ...(publishAt ? { publishAt } : {}),
      selfDeclaredMadeForKids: false,
    },
  },
  media: { body: fs.createReadStream(file) },
});

console.log(`https://www.youtube.com/watch?v=${res.data.id}`, res.data.status);

part names the resource sections you are writing (here snippet for title and description, status for visibility) and also decides which sections come back in the response. The reference lists a maximum file size of 256GB and accepts any video/* MIME type, which is far beyond anything a Short needs.

One honest limit: the Node client streams the file to YouTube as a single multipart request. It does not resume a broken transfer part-way. For a Short of a few megabytes that rarely matters — retry the whole call — but if you are pushing large files over an unreliable link, Google documents a separate resumable upload protocol you can speak over plain HTTP.

Step 5: schedule with publishAt

The videos resource describes status.publishAt as “The date and time when the video is scheduled to publish. It can be set only if the privacy status of the video is private.” The value is an ISO 8601 timestamp such as the output of new Date().toISOString(), and two details catch people out:

  • If the time is already in the past, the video is published immediately, exactly as if you had switched it from private to public.
  • The property can only be set on a video that is private and has never been published, so you cannot use it to re-schedule something that has already gone live.

Scheduling by publishAt means your script can upload a week of Shorts in one sitting and let YouTube release them, instead of needing a machine awake at every release time.

What makes an upload a Short

There is no Shorts field in the videos resource and no Shorts parameter on videos.insert. YouTube decides from the file itself. The YouTube Help page on three-minute Shorts states that for standard channels, videos uploaded on or after October 15, 2024 “with a square or vertical aspect ratio up to three minutes in length will be categorized as Shorts on YouTube”. The Studio upload instructions give the same two conditions: “Up to 3 minutes. With a square or vertical aspect ratio.”

In practice that means rendering a frame that is taller than it is wide (or exactly square) and keeping the duration at or under three minutes. A landscape file, or one that runs past three minutes, uploads fine through the same call but lands as a regular video. Putting #Shorts in the title, as the example does, is a common habit; the Help pages quoted above base the classification on aspect ratio and length, so do not rely on the hashtag to rescue a landscape file.

Trap 1: old guides say every upload is stuck on private

Search for why an API upload came out private and most answers describe a rule Google’s upload reference no longer states. The videos.insert reference now says, in its note on status.privacyStatus: “Videos uploaded from unverified API projects are not restricted to private viewing mode.” The same note says: “If you don’t specify a value for this property, the uploaded video’s privacy status defaults to public.”

That rule has a date. The API’s revision history entry for July 28, 2020 announced: “All videos uploaded via the videos.insert endpoint from unverified API projects created after 28 July 2020 will be restricted to private viewing mode. To lift this restriction, each project must undergo an audit to verify compliance with the Terms of Service.” The videos.insert reference repeated the first of those sentences as late as 29 September 2026, the day this page was first published; fetched again on 13 October 2026, it carried the “not restricted” sentence instead. In short: Google’s videos.insert reference now says videos uploaded from unverified API projects are not restricted to private viewing mode, and the private-until-audit lock that older guides describe is a rule announced in July 2020.

The reference still names one reason an upload can start out private: “Certain uploads might default to private based on creator account settings, as explained in the YouTube Help Center.” The Help Center page it links says: “The default video privacy setting for creators aged 13–17 is private. If you’re 18 or older, your default video privacy setting is set to public.” Also remember that the Step 4 script deliberately sends private whenever you pass a publishAt time.

dankdev did not upload a video to test any of this. This section reports what Google’s pages said on 13 October 2026, not behaviour observed on a real channel.

The audit still has a documented job, and it is quota. The quota and compliance audits page says: “If you would like to request additional quota beyond the default allocation, you must first complete an audit to show that your project is in compliance with the YouTube API Services Terms of Service.” Trap 2 covers when you would need that.

Trap 2: the daily upload quota

Old tutorials quote a per-upload cost in “units” drawn from one shared daily pool, and conclude you get about six uploads a day. That model has been replaced. The quota cost page now says: “Projects that enable the YouTube Data API have a default quota allocation of 100 search.list calls, 100 videos.insert calls, and 10,000 units per day combined for all other endpoints.” The videos.insert reference agrees: “A call to this method has a quota cost of 1 unit in the Video Uploads quota bucket.”

So the upload ceiling is plain division:

100 calls per day ÷ 1 unit per upload = 100 uploads per day, per Cloud project.

Two things still draw on the 10,000-unit general pool. Setting a custom thumbnail with thumbnails.set costs 50 units, and editing a video’s metadata afterwards with videos.update costs 50 units, so 10,000 ÷ 50 = 200 of either per day. Quotas “reset at midnight Pacific Time (PT)”, which is worth knowing if your scheduler runs in another time zone. One oddity if you read that page yourself: its auto-generated summary box still says videos.insert has “the highest cost of 1600 points” — the old model, under which 10,000 ÷ 1,600 = 6.25 meant six uploads a day. The cost table and the rule text on the same page both say 1 per call in a 100-per-day bucket; trust those.

The fix if 100 a day across all your channels is not enough is the audit described in Trap 1, started with the YouTube API Services Audit and Quota Extension Form. Before you apply, read the YouTube API Services Terms of Service and make sure your client actually complies, because that compliance is what the audit checks.

Trap 3: the refresh token dies after a week

The script works on Monday and fails with invalid_grant the following Monday. Google’s OAuth documentation explains why: a project “with an OAuth consent screen configured for an external user type and a publishing status of “Testing” is issued a refresh token expiring in 7 days”. An app whose OAuth consent screen is set to the external user type and left in the “Testing” publishing status is issued refresh tokens that expire in 7 days.

The fix is to move the consent screen’s publishing status from Testing to In production, then run get-token.mjs again for each channel.

Route 2: browser automation of YouTube Studio

The alternative is to script a real browser through the same YouTube Studio screens a person would click. The pipeline in YouTube Shorts automation on a Raspberry Pi takes this route: its upload step is “browser automation with channel switching, not the official YouTube Data API”. That post describes the flow as channel switching, overlay dismissal (with a self-learning cache for the pop-ups Studio throws up), title and description entry, privacy settings and publish confirmation, with results logged to an audit trail.

Its published numbers are the honest picture of what this costs: 72 videos uploaded across 5 channels over about 18 days, and an 83.7% upload success rate — 14 failures, most recovered on retry. That is 72 ÷ (72 + 14) = 83.7%. The same post’s own lesson is that “YouTube Studio UI changes can break the upload flow at any time”. Nothing in a browser flow is a contract; a renamed button is an outage.

What browser automation does sidestep is the API’s own machinery: there is no Cloud project, so no API quota bucket and no audit, and the session is the channel owner’s own login rather than an OAuth token that can expire in Testing. Switching between channels is a menu action inside one signed-in session, not a separate token per channel.

What YouTube’s Terms of Service say about automated access

This page will not tell you what a clause means for your situation; here is the text, so you can decide. Among the things the YouTube Terms of Service say you are not allowed to do:

access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law;

The API route is governed by a different document, the YouTube API Services Terms of Service, which is also what the quota audit measures you against.

API or browser automation: side by side

YouTube Data APIBrowser automation of Studio
Quota100 uploads per day per Cloud project by default; more via the audit formNo API quota; bounded by how long each upload flow takes
AuditNot needed to publish: the videos.insert reference says unverified projects’ uploads are not restricted to private (the lock was a 2020 rule); required for quota beyond the defaultNone
FragilityDocumented, versioned interface; changes are announced in the revision historyBreaks when Studio’s UI changes; the Pi pipeline reports 83.7% success
TermsYouTube API Services Terms of Service; the audit checks complianceYouTube Terms of Service clause on automated means, quoted above
Multi-channelOne refresh token per channel, all sharing the project’s quotaChannel switching inside one signed-in browser session
Schedulingstatus.publishAt on a private uploadWhatever Studio’s own schedule controls allow, clicked by the script

Which should you pick

If you are building something you intend to keep running, start with the API. The code above is the whole integration, the failure modes are documented error responses instead of missing buttons, and the two walls a new project hits have official fixes: the audit form raises the quota ceiling, and moving the consent screen to production ends the weekly token expiry. If you will need more than 100 uploads a day, apply for the audit early.

Browser automation needs no project, no audit and no token, and that is its whole appeal. Before choosing it, read the quoted clause above against your own circumstances, and budget for maintenance: expect the upload step to be the part of the system that breaks first.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top