How to Build a YouTube Upload Bot with Node.js and OAuth2

APIs #youtube#nodejs#api#automation#oauth2
How to Build a YouTube Upload Bot with Node.js and OAuth2

What You’ll Need

  • n8n Cloud or self-hosted n8n for workflow orchestration
  • Hetzner VPS or Contabo VPS for hosting your Node.js bot
  • Namecheap if you need a domain for webhooks
  • Node.js 18+ installed locally
  • A Google/YouTube developer account with API credentials
  • FFmpeg installed (for video processing)
  • Basic understanding of OAuth2 flow

Table of Contents


Understanding the YouTube Upload Workflow

I’ve built dozens of YouTube automation workflows over the past three years, and the pattern is always the same: authenticate once, grab an access token, upload videos, refresh tokens before they expire. The tricky part isn’t the code—it’s understanding when and why OAuth2 needs refreshing, and handling edge cases like failed uploads or network timeouts.

YouTube’s Data API v3 requires OAuth2 because you’re acting on behalf of a user’s account. Unlike simple API keys, OAuth2 gives you delegated access that users can revoke anytime. Your bot will need to:

  1. Exchange a refresh token for a short-lived access token
  2. Use that token to upload video files to YouTube
  3. Update video metadata (title, description, tags, privacy settings)
  4. Handle token expiration gracefully

The beauty of building this in Node.js is speed and ecosystem maturity. Libraries like googleapis and dotenv are battle-tested in production. If you’re comparing lightweight AI options for automating video descriptions or titles during upload, Claude Haiku vs GPT-4o Mini for Automation Pipelines walks through real trade-offs.


Setting Up Google OAuth2 Credentials

Head to Google Cloud Console and create a new project. I’ll walk through the exact steps:

  1. Click “Select a Project” → “New Project”
  2. Name it something obvious like youtube-uploader-bot
  3. Once created, navigate to “APIs & Services” → “Credentials”
  4. Click “Create Credentials” → “OAuth 2.0 Client ID”
  5. You’ll be prompted to create a consent screen first. Choose “External” for user type
  6. Fill in the app name, user support email, and developer contact
  7. On “Scopes,” add https://www.googleapis.com/auth/youtube.upload
  8. Back to Credentials, create an OAuth 2.0 Client ID as a “Desktop application”
  9. Download the JSON file—this contains your client_id and client_secret

Save that JSON file as credentials.json in your project root. Never commit this to git.

Now create a .env file to store sensitive values:

GOOGLE_CLIENT_ID=YOUR_CLIENT_ID_HERE
GOOGLE_CLIENT_SECRET=YOUR_CLIENT_SECRET_HERE
REDIRECT_URI=http://localhost:3000/oauth2callback
YOUTUBE_CHANNEL_ID=UCXXXXXXXXXXXXXXXXXX
NODE_ENV=production

Building the Core Node.js Bot

Install dependencies first:

npm init -y
npm install googleapis dotenv express axios path os stream

Create auth.js to handle the OAuth2 flow:

const { google } = require('googleapis');
const fs = require('fs');
require('dotenv').config();

const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.REDIRECT_URI
);

function getAuthUrl() {
  const scopes = ['https://www.googleapis.com/auth/youtube.upload'];
  
  const url = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: scopes,
    prompt: 'consent'
  });
  
  return url;
}

function saveRefreshToken(tokens) {
  const tokenPath = 'tokens.json';
  fs.writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
  console.log(`Refresh token saved to ${tokenPath}`);
}

function loadRefreshToken() {
  const tokenPath = 'tokens.json';
  if (fs.existsSync(tokenPath)) {
    const tokens = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
    oauth2Client.setCredentials(tokens);
    return tokens;
  }
  return null;
}

async function exchangeCodeForToken(code) {
  try {
    const { tokens } = await oauth2Client.getToken(code);
    saveRefreshToken(tokens);
    oauth2Client.setCredentials(tokens);
    return tokens;
  } catch (error) {
    console.error('Error exchanging code for token:', error.message);
    throw error;
  }
}

async function getAccessToken() {
  const tokens = loadRefreshToken();
  
  if (!tokens) {
    throw new Error('No refresh token found. Run initial authentication first.');
  }
  
  oauth2Client.setCredentials(tokens);
  
  if (oauth2Client.isTokenExpiring()) {
    console.log('Token expiring, refreshing...');
    const { credentials } = await oauth2Client.refreshAccessToken();
    saveRefreshToken(credentials);
    oauth2Client.setCredentials(credentials);
    return credentials.access_token;
  }
  
  return tokens.access_token;
}

module.exports = {
  oauth2Client,
  getAuthUrl,
  exchangeCodeForToken,
  getAccessToken,
  loadRefreshToken,
  saveRefreshToken
};

Now create server.js to set up the Express server and handle the OAuth callback:

const express = require('express');
const { getAuthUrl, exchangeCodeForToken, loadRefreshToken } = require('./auth');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 3000;

app.get('/auth', (req, res) => {
  const authUrl = getAuthUrl();
  res.redirect(authUrl);
});

app.get('/oauth2callback', async (req, res) => {
  const code = req.query.code;
  
  if (!code) {
    return res.status(400).send('No authorization code received');
  }
  
  try {
    const tokens = await exchangeCodeForToken(code);
    res.send('Authorization successful! You can close this window and run your upload script.');
  } catch (error) {
    console.error('OAuth error:', error.message);
    res.status(500).send('Authorization failed: ' + error.message);
  }
});

app.get('/health', (req, res) => {
  const tokens = loadRefreshToken();
  if (tokens) {
    res.json({ status: 'authenticated', channelId: process.env.YOUTUBE_CHANNEL_ID });
  } else {
    res.json({ status: 'not_authenticated', authUrl: 'http://localhost:3000/auth' });
  }
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
  console.log(`Visit http://localhost:${PORT}/auth to authenticate`);
});

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.


Handling OAuth2 Token Refresh

Token refresh is critical. YouTube access tokens expire after one hour, but refresh tokens last much longer (until revoked). The oauth2Client.isTokenExpiring() check in auth.js handles this automatically, but let me show you a more robust approach for production:

Create tokenManager.js:

const fs = require('fs');
const { oauth2Client } = require('./auth');
require('dotenv').config();

const TOKEN_FILE = 'tokens.json';
const TOKEN_REFRESH_THRESHOLD = 300; // Refresh if expiring in next 5 minutes

class TokenManager {
  constructor() {
    this.tokens = this.loadTokens();
  }

  loadTokens() {
    if (fs.existsSync(TOKEN_FILE)) {
      return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'));
    }
    return null;
  }

  saveTokens(tokens) {
    fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2));
    this.tokens = tokens;
  }

  isExpiringSoon() {
    if (!this.tokens || !this.tokens.expiry_date) {
      return true;
    }
    const now = Date.now();
    const expiryTime = this.tokens.expiry_date;
    return (expiryTime - now) < (TOKEN_REFRESH_THRESHOLD * 1000);
  }

  async getValidAccessToken() {
    if (!this.tokens) {
      throw new Error('No tokens found. Authenticate first.');
    }

    oauth2Client.setCredentials(this.tokens);

    if (this.isExpiringSoon()) {
      console.log(`[${new Date().toISOString()}] Token expiring soon, refreshing...`);
      try {
        const { credentials } = await oauth2Client.refreshAccessToken();
        this.saveTokens(credentials);
        oauth2Client.setCredentials(credentials);
        console.log(`[${new Date().toISOString()}] Token refreshed successfully`);
        return credentials.access_token;
      } catch (error) {
        console.error('Token refresh failed:', error.message);
        throw error;
      }
    }

    return this.tokens.access_token;
  }
}

module.exports = new TokenManager();

Implementing Video Upload Logic

Now for the main event—uploading videos. Create uploader.js:

const { google } = require('googleapis');
const fs = require('fs');
const path = require('path');
const tokenManager = require('./tokenManager');
const { oauth2Client } = require('./auth');
require('dotenv').config();

const youtube = google.youtube('v3');

async function uploadVideo(videoFilePath, metadata) {
  try {
    const accessToken = await tokenManager.getValidAccessToken();
    oauth2Client.setCredentials({ access_token: accessToken });

    const fileName = path.basename(videoFilePath);
    const fileSize = fs.statSync(videoFilePath).size;

    console.log(`[${new Date().toISOString()}] Starting upload: ${fileName} (${fileSize} bytes)`);

    const response = await youtube.videos.insert(
      {
        part: 'snippet,status,processingDetails',
        requestBody: {
          snippet: {
            title: metadata.title || 'Untitled Video',
            description: metadata.description || '',
            tags: metadata.tags || [],
            categoryId: metadata.categoryId || '22',
            defaultLanguage: metadata.defaultLanguage || 'en',
            defaultAudioLanguage: metadata.defaultAudioLanguage || 'en'
          },
          status: {
            privacyStatus: metadata.privacyStatus || 'private',
            publishAt: metadata.publishAt || null,
            selfDeclaredMadeForKids: metadata.madeForKids || false
          }

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

📬 Get Weekly Automation Tips

One email per week with tutorials, tools, and workflows. No spam, unsubscribe anytime.

Subscribe Free →