Skip to content
Social SDK Cloud, the hosted SDK, is taking early-access signups.Join the list
Social SDK megaphone markSocial SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

YouTube

Upload a video to a selected YouTube channel with explicit audience, visibility, media processing, and reconciliation fields.

To upload a video to YouTube from a server, start a resumable upload with the Data API’s videos.insert method, send the file to the upload URL it returns, and authorize both calls with an OAuth access token that has the youtube.upload scope. The Social SDK YouTube adapter does this through social.posts.publish: you pass the video, a title, a visibility and the made-for-kids declaration, and it returns processing while YouTube processes the upload, or published with the video ID once processing is done.

For API keys, OAuth scopes and what each Data API call costs in quota, read the YouTube Data API guide. For the raw resumable upload protocol, scopes and the daily upload quota, read the YouTube upload API guide. For how YouTube compares with the other platform APIs and unified social media APIs, read the social media APIs guide.

npm install @opencoredev/social-sdk
pnpm add @opencoredev/social-sdk
yarn add @opencoredev/social-sdk
bun add @opencoredev/social-sdk
nub add @opencoredev/social-sdk
aube add @opencoredev/social-sdk
import { readFile } from "node:fs/promises";
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { youtube } from "@opencoredev/social-sdk/youtube";

const social = createSocial({
  backend: youtube({
    auth: {
      accessToken: process.env["YOUTUBE_ACCESS_TOKEN"]!,
      channelId: process.env["YOUTUBE_CHANNEL_ID"]!,
    },
  }),
});

const account = connectedAccountRef({
  backend: "default",
  platform: "youtube",
  accountId: process.env["YOUTUBE_CHANNEL_ID"]!,
});

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: "Video description",
    media: [
      {
        kind: "video",
        source: { kind: "blob", blob: video, fingerprint: "release-notes-v1" },
        mimeType: "video/mp4",
        byteSize: video.size,
      },
    ],
  },
});

This sample uploads the video as private. On a server, also pass saveUploadSession as in the setup below, so an interrupted upload can be resumed instead of starting a second video.

The direct YouTube adapter accepts an existing OAuth access token and channel ID. The server package also provides youtubeOAuth for Google authorization-code connections and channel discovery; use ConnectionManager to keep the attempt server-side, present discovered channels for selection, and persist credentials through an encrypted credentialSink.

Setup

  1. Create a Google Cloud project, enable YouTube Data API v3, and enable YouTube Analytics API if you need reports. Configure the OAuth consent screen, audience, test users, and the scopes below.
  2. Create a web application OAuth client. Register the exact HTTPS redirect URI used by your server callback. Keep its client secret on the server. YouTube does not support service-account authorization for ordinary channels.
  3. Follow Connect accounts with youtubeOAuth and ConnectionManager. Include https://www.googleapis.com/auth/youtube.readonly for channel discovery and the scopes needed by your operations. Present the discovered channels for selection and store the selected channel’s token through an encrypted credentialSink.
  4. Use the discovered account’s accountId as channelId, and its stored token.accessToken as accessToken. Refresh with refreshOAuthToken("youtube", ...) and persist any replacement refresh token. The direct adapter does not refresh tokens itself.

The Google authorize URL always carries access_type=offline and prompt=consent so Google returns a refresh token. Set includeGrantedScopes: true on youtubeOAuth to send include_granted_scopes=true (incremental authorization: the new token also covers scopes the user already granted your app). A loginHint passed to ConnectionManager.begin (or the connect handler’s loginHint field) is sent as login_hint, an email address or Google sub ID that preselects the account.

To disconnect a channel, revoke its grant with revokeGoogleOAuthToken(options, { token }). It posts the access or refresh token as a form to https://oauth2.googleapis.com/revoke; revoking an access token also revokes its refresh token. Google answers 200 on success and 400 (raised as upstream_failure) for an unknown or already revoked token. It returns "revoked", so it can back OAuthConnectHandlersOptions.grants.revoke.

import { revokeGoogleOAuthToken } from "@opencoredev/social-sdk/server";

await revokeGoogleOAuthToken({}, { token: storedRefreshToken });

External OAuth apps in Testing receive refresh tokens that expire after seven days when requesting YouTube scopes. Reconnect after expiry. OAuth consent verification and the YouTube API compliance audit are separate approvals.

Scopes by operation

These are operation-specific choices, not a request to grant every scope. Use the full URLs in your youtubeOAuth({ scopes: [...] }) configuration.

Operation Scope
Channel discovery and private Data API reads https://www.googleapis.com/auth/youtube.readonly
Video upload https://www.googleapis.com/auth/youtube.upload
Video update, delete, schedule cancellation; channel updates; live broadcasts https://www.googleapis.com/auth/youtube
Playlist and subscription writes https://www.googleapis.com/auth/youtube or https://www.googleapis.com/auth/youtube.force-ssl
Comment writes, moderation, caption writes/downloads, ratings https://www.googleapis.com/auth/youtube.force-ssl
Analytics reports https://www.googleapis.com/auth/yt-analytics.readonly
Monetary Analytics metrics https://www.googleapis.com/auth/yt-analytics-monetary.readonly
Live chat messages, moderators, and bans; channel section writes https://www.googleapis.com/auth/youtube
Channel sections and activity reads https://www.googleapis.com/auth/youtube.readonly
Video, channel, category, language and region lookups; getRating; subscriber lists https://www.googleapis.com/auth/youtube.readonly
Top-level comments, abuse reports https://www.googleapis.com/auth/youtube.force-ssl
Live broadcast update, delete, bind, cuepoint; live stream writes https://www.googleapis.com/auth/youtube or https://www.googleapis.com/auth/youtube.force-ssl
Live stream lists and Super Chat events https://www.googleapis.com/auth/youtube.readonly
Channel banner upload https://www.googleapis.com/auth/youtube.upload or https://www.googleapis.com/auth/youtube
Watermarks and playlist images https://www.googleapis.com/auth/youtube or https://www.googleapis.com/auth/youtube.force-ssl
Channel members and membership levels (approval required) https://www.googleapis.com/auth/youtube.channel-memberships.creator

youtube.readonly cannot authorize mutations. Account/post statistics use the Data API read scope; Analytics reports use the separate Analytics scope. Public reads may have different upstream authorization requirements, but this adapter always uses its configured OAuth token.

For a connection that discovers channels and uploads videos, configure the provider with these scopes and your encrypted credential store:

import {
  youtubeOAuth,
  type ConnectionManager,
  type OAuthCredentialSink,
} from "@opencoredev/social-sdk/server";

function createYouTubeConnectionProvider(
  credentialSink: OAuthCredentialSink,
  selectedChannelId: string,
) {
  return youtubeOAuth({
    clientId: requiredEnv("GOOGLE_CLIENT_ID"),
    clientSecret: requiredEnv("GOOGLE_CLIENT_SECRET"),
    redirectUri: requiredEnv("GOOGLE_REDIRECT_URI"),
    scopes: [
      "openid",
      "https://www.googleapis.com/auth/youtube.readonly",
      "https://www.googleapis.com/auth/youtube.upload",
    ],
    credentialSink,
    selectAccounts: () => [selectedChannelId],
  });
}

async function completeYouTubeConnection(
  manager: ConnectionManager,
  credentialSink: OAuthCredentialSink,
  selectedChannelId: string,
  input: Omit<Parameters<ConnectionManager["complete"]>[0], "provider" | "selectedAccountIds">,
) {
  return manager.complete({
    ...input,
    provider: createYouTubeConnectionProvider(credentialSink, selectedChannelId),
    selectedAccountIds: [selectedChannelId],
  });
}

This example assumes the chosen channel ID is known before callback discovery. Load it from server-side state for the authenticated tenant and principal. The provider persists credentials only for that ID, and ConnectionManager.complete grants access to the same channel. An ID absent from discovery is rejected before credentials are saved. For a picker shown after discovery, use an encrypted attempt-scoped token store during discovery and promote only the chosen channel’s credentials after ConnectionManager.select. Supply the provider to the connection flow in the linked guide. Add write or Analytics scopes only for operations your application offers. The adapter configuration below loads the selected channel and token from server configuration.

import { readFile, writeFile } from "node:fs/promises";
import {
  createSocial,
  connectedAccountRef,
  type AdapterOperationContext,
  type MediaAttachment,
} from "@opencoredev/social-sdk";
import { youtube, type YouTubeNative } from "@opencoredev/social-sdk/youtube";

type UploadSession = Parameters<YouTubeNative["queryUpload"]>[0];

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Configure ${name} on the server.`);
  return value;
}

const channelId = requiredEnv("YOUTUBE_CHANNEL_ID");
// Use an application-owned private directory. Never expose this file to clients.
const sessionPath = requiredEnv("YOUTUBE_UPLOAD_SESSION_PATH");
const backend = youtube({
  auth: { accessToken: requiredEnv("YOUTUBE_ACCESS_TOKEN"), channelId },
  saveUploadSession: async (session) => {
    await writeFile(sessionPath, JSON.stringify(session), { mode: 0o600 });
  },
});
const social = createSocial({ backend });
const account = connectedAccountRef({
  backend: "default",
  platform: "youtube",
  accountId: channelId,
});

const native = social.native("default", { acknowledgeUnsafe: true });
if (!native) throw new Error("YouTube native operations are unavailable.");
const context: AdapterOperationContext = {
  backendInstance: "default",
  correlationId: crypto.randomUUID(),
  retryBudget: { maxAttempts: 1, maxElapsedMs: 30_000 },
};

Native access bypasses client middleware. Enforce tenant access in your server before calling it. Give each new operation a fresh context; requests sharing one context consume its elapsed budget.

Examples

Upload a video

Preparation requires exactly one video as a Blob or replayable stream, an actual video/* MIME type, explicit visibility, and explicit madeForKids. A stream needs its exact positive byteSize and an open() function that returns the same bytes each time. Remote URLs are rejected. Titles must contain 1 to 100 characters with no < or >; descriptions must contain no < or > and fit within 5000 UTF-8 bytes, not just 5000 characters.

Set target option categoryId to include the category in the upload’s snippet metadata. Omitting it retains the provider’s default behavior.

The direct adapter also accepts these optional target options and writes them into the videos.insert request. Each is validated before any request; an invalid value fails preparation with invalid_input and an issue code such as youtube.tags. Managed backends map their own YouTube option sets instead, on top of title, visibility, and madeForKids: Zernio maps categoryId, tags, playlistId, containsSyntheticMedia, firstComment, and notifySubscribers; Post for Me maps categoryId, tags, containsSyntheticMedia, and paidProductPlacement; PostFast maps isShort, tags, playlistId, containsSyntheticMedia, firstComment, language, and captionKey; Postiz maps tags; and Buffer maps categoryId, embeddable, license, notifySubscribers, and aiGenerated. They reject other options as unmapped.

Option Wire field What it does and its limits
tags snippet.tags Search keywords. YouTube allows 500 characters in total: commas between tags count, and a tag that contains a space counts two extra characters for the quotation marks YouTube adds. Tags cannot contain commas.
defaultLanguage snippet.defaultLanguage BCP-47 language of the title and description, such as en or pt-BR. Required before localizations.
localizations localizations.{lang}.title/description Translated title (1 to 100 characters) and optional description (at most 5000 UTF-8 bytes) per BCP-47 language. Adds localizations to part. YouTube answers defaultLanguageNotSet without defaultLanguage.
license status.license youtube (standard license) or creativeCommon.
embeddable status.embeddable false stops other sites from embedding the video.
publicStatsViewable status.publicStatsViewable false hides the extended statistics on the watch page. View count and ratings stay public.
containsSyntheticMedia status.containsSyntheticMedia Altered or synthetic content disclosure. Set true when realistic content was made or changed with AI or other tools, such as a real person appearing to say something they did not.
recordingDate recordingDetails.recordingDate When the video was recorded, as an ISO 8601 timestamp. Sent normalized to UTC and adds recordingDetails to part.
notifySubscribers notifySubscribers query parameter false uploads without notifying subscribers, for example during a bulk upload. YouTube defaults to true; the adapter sends the parameter only when you set it.

These need no scope beyond youtube.upload. Source: videos.insert and the videos resource.

const blob = new Blob([await readFile("./video.mp4")], { type: "video/mp4" });
const media: MediaAttachment = {
  kind: "video",
  source: { kind: "blob", blob, fingerprint: "video-file-v1" },
  mimeType: "video/mp4",
  byteSize: blob.size,
};

const uploadResult = await social.posts.publish({
  targets: [
    {
      account,
      options: { title: "A release", visibility: "private", madeForKids: false },
    },
  ],
  content: { text: "Description", media: [media] },
});

await social.posts.publish({
  targets: [
    {
      account,
      options: {
        title: "Launch day",
        visibility: "unlisted",
        madeForKids: false,
        tags: ["launch", "release notes"],
        defaultLanguage: "en",
        localizations: { de: { title: "Starttag", description: "Beschreibung" } },
        license: "creativeCommon",
        embeddable: true,
        publicStatsViewable: false,
        containsSyntheticMedia: false,
        recordingDate: "2026-10-01T09:30:00Z",
        notifySubscribers: false,
      },
    },
  ],
  content: { text: "Description", media: [media] },
});

Schedule a video

Pass schedule to upload now and let YouTube publish later. The adapter uploads the video as private with status.publishAt set to the scheduled time, and YouTube makes it public at that time. The time must be in the future. Scheduled release overrides visibility: even an unlisted request starts private and is released public.

const scheduledResult = await social.posts.publish({
  targets: [
    {
      account,
      options: { title: "A release", visibility: "private", madeForKids: false },
    },
  ],
  content: { text: "Description", media: [media] },
  schedule: { at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() },
});

While the video waits for its publish time, the outcome is scheduled and carries a job reference whose jobId is the video ID. After the time passes and YouTube publishes the video, getDelivery reports it as published.

Cancel a scheduled video

posts.cancelScheduled(job) stops YouTube from publishing the video at the scheduled time. It does not delete the video. The adapter reads the video, checks that it belongs to the configured channel, is still private, and has a publishAt in the future, then calls videos.update with part=status. The result is { state: "cancelled", backendRecord: "retained" }: the video stays uploaded and private with no scheduled time.

const scheduledOutcome = scheduledResult.outcomes[0];
if (scheduledOutcome?.state === "scheduled") {
  await social.posts.cancelScheduled(scheduledOutcome.job);
}

videos.update replaces the whole status part and resets any field left out, so the adapter resends the status fields it just read (license, embeddable, publicStatsViewable, selfDeclaredMadeForKids, and containsSyntheticMedia) and leaves out only publishAt. If YouTube does not return the made-for-kids declaration, the adapter stops before writing rather than risk clearing it.

Cancellation needs the https://www.googleapis.com/auth/youtube scope; youtube.upload alone cannot call videos.update. It costs 51 quota units: 1 for videos.list and 50 for videos.update. A video that is public, already past its publish time, or has no schedule is rejected with invalid_input and nothing is written. The update is sent once. If the response is lost or does not show a private video without publishAt, the call raises ambiguous_outcome with reconcile-first; read the video before trying again. To remove the video entirely, use posts.removeFromPlatform instead.

Track upload and processing separately

YouTube separates upload completion from processing. A private video with a future publishAt maps to scheduled. Otherwise uploaded maps to processing, processed maps to published, and failed/rejected maps to a confirmed media failure. The returned watch URL is produced only with a verified video ID.

const processingOutcome = uploadResult.outcomes[0];
if (processingOutcome?.state === "processing" && processingOutcome.delivery) {
  const delivery = await social.posts.getDelivery(processingOutcome.delivery);
  console.log(delivery.state);
}

Resume an interrupted upload

The setup’s saveUploadSession callback persists the session before video bytes are sent. Store each upload separately in production and protect the secret url with encryption and server-only access. The session must retain its size, mimeType, and channelId. Never log or return it to a browser. A non-complete upload is unknown; the adapter does not resume in the background.

Load and validate the saved session after a restart, then query it or resume with the same replayable video asset:

async function loadSession(): Promise<UploadSession> {
  const value: unknown = JSON.parse(await readFile(sessionPath, "utf8"));
  if (
    typeof value !== "object" ||
    value === null ||
    !("url" in value) ||
    typeof value.url !== "string" ||
    !("size" in value) ||
    typeof value.size !== "number" ||
    !Number.isSafeInteger(value.size) ||
    value.size <= 0 ||
    !("mimeType" in value) ||
    typeof value.mimeType !== "string" ||
    !("channelId" in value) ||
    typeof value.channelId !== "string"
  )
    throw new Error("Invalid stored upload session.");
  return { url: value.url, size: value.size, mimeType: value.mimeType, channelId: value.channelId };
}

const session = await loadSession();
const uploadStatus = await native.queryUpload(session, { ...context });
if (uploadStatus.state === "incomplete") {
  await native.resumeUpload(session, media, { ...context });
}

resumeUpload probes the server’s confirmed offset before sending remaining bytes. Publish and resume each get one deadline of at least 15 minutes, or the longer remaining caller budget. A resume probe and its chunks share that deadline. queryUpload uses the caller’s remaining budget as is, with no 15-minute minimum. Pass signal to cancel earlier. For an expired session (typically HTTP 404/410), confirm the old video’s state before starting a new upload; an expired URI cannot be resumed. Recreating a session can create another video.

Receive push notifications

YouTube sends new and updated videos through the PubSubHubbub hub. Subscribe with a hub.secret and pass the same value as webhookSecret; the hub then signs each delivery with X-Hub-Signature, and adapter.webhooks.verify checks it. Without a secret the hub sends unsigned notifications, so read the video through the API before acting on one. Answer the hub’s GET verification with answerYouTubeWebhookChallenge, listing the topics you subscribed to. The decoded event lists the videos in data.videos with type unknown. See Process webhooks.

POST form fields to https://pubsubhubbub.appspot.com/subscribe: hub.mode=subscribe, hub.topic=https://www.youtube.com/xml/feeds/videos.xml?channel_id=YOUR_CHANNEL_ID, hub.callback=YOUR_HTTPS_CALLBACK, hub.verify=async, hub.lease_seconds=86400, and hub.secret=YOUR_SERVER_SECRET. Use the same channel topic when answering the callback challenge:

import { answerYouTubeWebhookChallenge } from "@opencoredev/social-sdk/server";

function answerHubVerification(callbackUrl: string) {
  return answerYouTubeWebhookChallenge({
    query: new URL(callbackUrl).searchParams,
    topics: [
      `https://www.youtube.com/xml/feeds/videos.xml?channel_id=${encodeURIComponent(channelId)}`,
    ],
  });
}

Return the helper’s status, headers, and challenge body from your GET handler. Renew the subscription before the granted lease expires; the SDK does not renew it. Uploads and title/description changes share Atom entries, so fetch the current video instead of inferring which field changed.

Native operations

Provider-shaped operations are available on social.native:

const thumbnail: MediaAttachment = {
  kind: "image",
  mimeType: "image/jpeg",
  source: {
    kind: "blob",
    blob: new Blob([await readFile("./thumbnail.jpg")]),
    fingerprint: "thumbnail-v1",
  },
};
const caption: MediaAttachment = {
  kind: "document",
  filename: "captions.vtt",
  mimeType: "text/vtt",
  source: {
    kind: "blob",
    blob: new Blob([await readFile("./captions.vtt")]),
    fingerprint: "caption-v1",
  },
};
await native.playlists({ action: "list", mine: true, context });
await native.playlistItems({
  action: "insert",
  body: { snippet: { playlistId: "PL...", resourceId: { kind: "youtube#video", videoId: "v1" } } },
  context,
});
await native.setThumbnail({ videoId: "v1", thumbnail, context });
await native.updateVideo({
  videoId: "v1",
  body: {
    snippet: { title: "Updated title", description: "...", tags: ["sdk"], categoryId: "22" },
    status: { privacyStatus: "private" },
  },
  context,
});
await native.rateVideo({ videoId: "v1", rating: "like", context });
await native.captions({
  action: "insert",
  videoId: "v1",
  caption,
  body: { snippet: { language: "en", name: "English" } },
  context,
});
await native.subscriptions({ action: "list", context });
await native.commentsModeration({
  action: "setModerationStatus",
  commentId: "c1",
  moderationStatus: "heldForReview",
  context,
});
await native.deleteComment({ account, commentId: "Ugz...", context });

Update channel details

native.updateProfile writes one part of the configured channel through channels.update. Use part: "brandingSettings" with a channel object for the description, keywords, country, default language, tracking analytics account, and unsubscribed trailer. Use part: "localizations" for a map of language codes to localized title and description.

await native.updateProfile({
  part: "brandingSettings",
  value: { channel: { description: "New channel description", keywords: "sdk social" } },
  context,
});
await native.updateProfile({
  part: "localizations",
  value: { de: { title: "Mein Kanal", description: "Beschreibung" }, fr: null },
  context,
});

YouTube deletes any mutable property that a write leaves out, so the adapter reads the current part with channels.list and merges your fields into it before the write. A null field removes that property or localization. Set brandingSettings.channel.defaultLanguage before you add localizations. YouTube rejects a changed channel title with channelTitleUpdateForbidden, and the API has no write for the channel avatar or handle. The adapter resends the current banner URL unchanged and drops deprecated branding fields. To change the banner, upload it with native.uploadChannelBanner, which calls channelBanners.insert (a 16:9 JPEG or PNG of at most 6 MiB, at least 2048 by 1152 pixels, 50 units), then pass the returned url to updateProfile as brandingSettings image.bannerExternalUrl; the channel object is kept as it is. The call needs https://www.googleapis.com/auth/youtube and costs 51 quota units: 1 for the read and 50 for the write.

updateVideo reads the authorized video and merges only writable snippet and status fields. It retains the required title and category ID. Set an optional field to null to clear it; status.publishAt is omitted when privacy becomes public or unlisted. It also accepts a recordingDetails part (only recordingDate) and a localizations part (language to { title, description? }). When either is present the adapter reads that part too and merges your entries into it, because videos.update deletes whatever a written part leaves out; null removes the recording date or a localization. Localizations need snippet.defaultLanguage, retained or supplied. Unsupported parts and fields are rejected locally.

await native.updateVideo({
  videoId: "v1",
  body: {
    recordingDetails: { recordingDate: "2026-10-02T08:00:00Z" },
    localizations: { de: { title: "Neuer Titel" }, es: null },
  },
  context,
});

Thumbnails must be Blob inputs; Media specifications lists the types and size thumbnails.set accepts. Custom thumbnails require channel eligibility; an upstream 403 raises missing_permission. The example uses an existing JPEG file. Since November 3, 2026, setThumbnail also works for YouTube Shorts, but only after processing finishes: wait until processingDetails.processingStatus is succeeded (read it with native.videos and parts: ["processingDetails"]), because YouTube may silently skip a Shorts thumbnail sent earlier.

const banner = await native.uploadChannelBanner({ image: bannerImage, context });
await native.updateProfile({
  part: "brandingSettings",
  value: { image: { bannerExternalUrl: String(banner["url"]) } },
  context,
});
await native.watermarks({
  action: "set",
  image: watermarkImage,
  timing: { type: "offsetFromEnd", offsetMs: 15_000, durationMs: 10_000 },
  context,
});
await native.playlistImages({
  action: "insert",
  playlistId: "PL...",
  image: squareImage,
  context,
});

native.watermarks sets or removes the channel watermark, the branding image shown on the channel’s videos, with watermarks.set and watermarks.unset. set sends a JPEG or PNG of at most 10 MiB and timing: type is offsetFromStart or offsetFromEnd, offsetMs is when the watermark appears (at least 1000 from the end), and the optional durationMs is at least 1000 and must fit before the end. targetChannelId changes the channel the watermark links to. YouTube places it in the upper right corner. Each call costs 50 units.

native.playlistImages manages a playlist’s custom thumbnail with playlistImages list, insert, update, and delete. The image is square (1:1), JPEG or PNG, at most 50 MB, and is sent with snippet.type hero. Lists filter by playlist with the parent parameter named in Google’s discovery document (the reference page calls it playlistId). Writes cost 50 units; a list costs 1.

Caption insertion needs metadata (language, name, and the selected video’s ID) and a caption Blob. Updates can change only snippet.isDraft without media, or replace bytes without a snippet (part=id). Other update metadata, including name and language, is rejected locally with invalid_input; YouTube accepts only id and snippet.isDraft for updates. Supply isDraft explicitly to change draft status; a draft track is not visible to viewers. Download with action: "download" and a captionId; downloads are bounded by the caller’s remaining elapsed budget and honor cancellation. format sets tfmt: sbv, scc, srt, ttml or vtt (the default). format: "original" omits tfmt, so YouTube returns the track as it was uploaded. tlang (a language code such as fr) asks YouTube for a machine translation of the track. A download costs 200 units.

const french = await native.captions({
  action: "download",
  videoId: "v1",
  captionId: "AUieDa...",
  format: "srt",
  tlang: "fr",
  context,
});

Held-for-review threads use native.heldComments. Google’s comments.delete reference does not say which comments a channel may delete; it documents only a 403 forbidden when the authorization is insufficient. Pass the top-level comment ID, not a reply-thread container. A forbidden deletion raises missing_permission. To remove another user’s comment from your video, use commentsModeration with moderationStatus: "rejected". Rejected moderation is irreversible through the API. Comment replies verify the parent thread’s video before inserting a reply.

Update playlists safely

Playlist writes replace each selected part. Resend the complete mutable fields for that part, including description and privacy when writing them. The adapter injects the selected ID and rejects a conflicting body ID. It only selects parts present in your body.

await native.playlists({
  action: "update",
  playlistId: "PL...",
  body: {
    snippet: { title: "Releases", description: "Release videos" },
    status: { privacyStatus: "private" },
  },
  context,
});
await native.playlistItems({
  action: "update",
  playlistItemId: "playlist-item-id",
  body: {
    snippet: {
      playlistId: "PL...",
      position: 0,
      resourceId: { kind: "youtube#video", videoId: "v1" },
    },
  },
  context,
});

Moving an item requires a playlist with manual ordering and the playlist-item ID returned by playlistItems.list; a video ID identifies the video and a playlist ID identifies its container. A snippet-only move leaves contentDetails.note untouched. If you write contentDetails, resend any note or other writable fields you want to keep.

Localize playlist titles

native.playlists writes the localizations part on insert and update: a map from a BCP-47 language tag to a translated title and description. Set snippet.defaultLanguage in the same body, naming the language of the playlist’s own title and description; without it the adapter rejects the write with invalid_input before any request. An update replaces each part you send, so resend every language you want to keep in localizations, and the complete snippet (title, description and defaultLanguage) with it.

await native.playlists({
  action: "insert",
  body: {
    snippet: { title: "Releases", description: "Release videos", defaultLanguage: "en" },
    localizations: { fr: { title: "Versions", description: "Vidéos des versions" } },
  },
  context,
});
await native.playlists({
  action: "list",
  playlistId: "PL...",
  parts: ["snippet", "localizations"],
  context,
});

Lists return snippet, status and contentDetails unless you pass parts, drawn from contentDetails, id, localizations, player, snippet and status. Request localizations to read the translations back.

Every playlist, playlist item, subscription, channel section, live broadcast, live stream, live chat message, moderator and ban write, caption and playlist image upload, comment thread, Analytics group and group item, and Reporting job create must answer with the written resource and its non-empty id. A success response without one raises ambiguous_outcome with reconcile-first, because Google may have created the resource; list it before retrying. An Analytics group item that Google answers with 204 No Content still returns { alreadyInGroup: true }. Writes to an existing resource must return that resource: video, channel, and comment updates, ad cuepoints, and poll closure need the addressed id; liveBroadcasts bind also needs contentDetails.boundStreamId to match streamId (or be absent after an unbind), and transition needs status.lifeCycleStatus to be the requested state or its starting step (testStarting, liveStarting). A thumbnail set needs its items, and an abuse report its subject. Anything less raises ambiguous_outcome with reconcile-first.

Search and Analytics

Normalized social.search.posts(account, { query, startTime, endTime }) maps the ISO date bounds to publishedAfter and publishedBefore, with type=video. Only the recent scope is accepted and pages contain at most 50 items. Use native.search for channelId, order, or a different result type.

native.search also passes the documented search.list filters: regionCode (ISO 3166-1 alpha-2), relevanceLanguage, safeSearch (moderate, none, strict), and topicId. These need type: "video" and are rejected locally without it: eventType (live, upcoming, completed), videoDuration (short under 4 minutes, medium 4 to 20, long over 20), videoDefinition, videoCaption, videoLicense, videoType, videoCategoryId, videoEmbeddable (true keeps videos that can be embedded), videoSyndicated (true keeps videos playable outside youtube.com), videoDimension (2d, 3d or any), videoPaidProductPlacement (true keeps videos the creator marked as containing a paid promotion), and location, a latitude and longitude with a radius such as 5km of at most 1000 km. channelType: "show" restricts channel results to shows. Each search page costs 1 unit from the separate search bucket of 100 calls per day.

Two owner filters narrow the search to your own content; set at most one. forMine: true searches only the authorized channel’s videos, private ones included. It requires type: "video", and YouTube rejects it together with videoDefinition, videoDimension, videoDuration, videoEmbeddable, videoLicense, videoPaidProductPlacement, videoSyndicated and videoType; the adapter rejects those combinations before sending. forDeveloper: true searches only videos uploaded through your Google Cloud project, identified by the OAuth client, and combines with q. The partner-only forContentOwner filter is listed under Not supported.

const liveNow = await native.search({
  q: "launch",
  type: "video",
  eventType: "live",
  regionCode: "US",
  relevanceLanguage: "en",
  safeSearch: "strict",
  videoDuration: "long",
  context,
});
const report = await social.analytics.getReport(account, {
  from: "2026-01-01",
  to: "2026-01-31",
  metrics: ["views", "estimatedMinutesWatched"],
  dimensions: ["day"],
});
const filteredReport = await native.analytics({
  query: {
    ids: "channel==MINE",
    startDate: "2026-01-01",
    endDate: "2026-01-31",
    metrics: "views,estimatedMinutesWatched",
    dimensions: "day",
    filters: "video==v1",
    sort: "day",
  },
  context,
});

Reports need https://www.googleapis.com/auth/yt-analytics.readonly; monetary metrics need https://www.googleapis.com/auth/yt-analytics-monetary.readonly. Analytics has separate quota from the Data API and may lag current activity. Provider metric/dimension/filter combinations must be valid for the selected report.

Analytics groups

Analytics groups are named sets of up to 500 videos, playlists or channels that you report on together. native.analyticsGroups lists the caller’s groups (mine=true, or ids), inserts a group with a title and an itemType of youtube#video, youtube#playlist or youtube#channel, renames it with update, and deletes it. native.analyticsGroupItems lists a group’s items, adds one with resource: { kind, id } (every item in a group must be the group’s item type; adding an item that is already there returns { alreadyInGroup: true }, because Google answers 204 No Content), and deletes one by its group item ID. Then pass filters: "group==GROUP_ID" to native.analytics.

const group = await native.analyticsGroups({
  action: "insert",
  title: "Launch videos",
  itemType: "youtube#video",
  context,
});
await native.analyticsGroupItems({
  action: "insert",
  groupId: String(group["id"]),
  resource: { kind: "youtube#video", id: "v1" },
  context,
});

Bulk reports with the Reporting API

The YouTube Reporting API generates daily CSV reports in bulk instead of answering one query at a time. native.reportTypes lists the report types (such as channel_basic_a2); native.reportingJobs creates a job for one report type with a name of at most 100 characters, and lists, reads or deletes jobs; native.reports lists a job’s generated reports (filter with createdAfter, startTimeAtOrAfter and startTimeBefore) or reads one. Each report carries a downloadUrl; native.downloadReport fetches it as a Blob within the caller’s remaining budget and refuses any URL that is not a https://youtubereporting.googleapis.com/v1/media/ link, so the token never goes elsewhere. Reports appear only after YouTube generates them, not when the job is created. These calls use the Analytics scopes above and do not count against the Data API quota.

const job = await native.reportingJobs({
  action: "create",
  reportTypeId: "channel_basic_a2",
  name: "Daily channel basics",
  context,
});
const generated = await native.reports({ action: "list", jobId: String(job["id"]), context });

Read any video or channel

native.videos calls videos.list for videos from any channel, not only the configured one. The filter is one of: { kind: "id", ids } (up to 50 IDs), { kind: "mostPopular", regionCode?, videoCategoryId? } for the chart=mostPopular trending chart, or { kind: "myRating", rating: "like" | "dislike" } for videos the authorized user rated (YouTube returns at most 1,000). parts defaults to snippet, contentDetails and statistics; it also accepts liveStreamingDetails (scheduled and actual start and end times, concurrentViewers, and activeLiveChatId while a broadcast has chat), topicDetails (topicCategories, Wikipedia URLs describing the content), localizations, recordingDetails, status, player and the other documented parts. fileDetails, processingDetails and suggestions are owner-only and return 403 for anyone else’s video. maxResults (1 to 50) and pageToken apply to the chart and rating lists and are rejected for an ID lookup. With the player part, maxWidth and maxHeight (72 to 8192 pixels each) size the iframe in player.embedHtml and make YouTube return player.embedWidth and player.embedHeight; the player keeps its aspect ratio, so with both set it fits inside the box. Each call costs 1 unit.

native.batchGetStats calls videos.batchGetStats, added on June 3, 2026, at GET /youtube/v3/videos:batchGetStats. It returns statistics (and optionally snippet.publishTime, contentDetails.duration and id) plus a summary with failedVideoIds. Google does not state a per-call ID limit; the adapter caps a call at 50 IDs, the videos.list limit. It costs 1 unit from its own bucket of 10,000 units per day, separate from the main Data API quota, so it suits polling view counts. analytics.getPostMetrics still uses videos.list because it also checks that the video belongs to the configured channel.

native.channels calls channels.list for another channel by { kind: "id", ids }, by handle (forHandle, with or without the @), or by legacy username (forUsername). parts defaults to snippet and statistics.

const trending = await native.videos({
  filter: { kind: "mostPopular", regionCode: "GB", videoCategoryId: "10" },
  maxResults: 25,
  context,
});
const live = await native.videos({
  filter: { kind: "id", ids: ["v1"] },
  parts: ["snippet", "liveStreamingDetails", "topicDetails"],
  context,
});
const embed = await native.videos({
  filter: { kind: "id", ids: ["v1"] },
  parts: ["player"],
  maxWidth: 640,
  context,
});
const stats = await native.batchGetStats({ videoIds: ["v1", "v2"], context });
const channel = await native.channels({
  filter: { kind: "handle", handle: "@GoogleDevelopers" },
  context,
});

native.videoTrainability({ videoId }) reads GET /youtube/v3/videoTrainability?id=, which reports who the creator allows to train AI models on a video. It returns videoId, permitted (["all"], ["none"], or a list of named parties) and the raw resource. The method is in Google’s discovery document (revision 20261103) but has no reference page or revision-history entry yet, so treat it as beta: the field meanings come from the discovery schema and may change.

Categories, languages and regions

native.videoCategories calls videoCategories.list by { kind: "region", regionCode } or { kind: "id", ids }, so you can choose the upload categoryId. native.i18n({ resource: "languages" }) calls i18nLanguages.list and resource: "regions" calls i18nRegions.list; use them to pick defaultLanguage, localizations keys, relevanceLanguage and regionCode. Each accepts hl for the language of the returned names and costs 1 unit.

const categories = await native.videoCategories({
  filter: { kind: "region", regionCode: "US" },
  context,
});
const languages = await native.i18n({ resource: "languages", hl: "en_US", context });

Comments, ratings and abuse reports

native.commentThreads({ action: "insert", videoId, text }) posts a top-level comment with commentThreads.insert. The adapter sends snippet.videoId, snippet.channelId (the configured channel unless you pass the video owner’s channelId), and snippet.topLevelComment.snippet.textOriginal. It needs youtube.force-ssl and costs 50 units. Use social.comments.reply to answer an existing comment.

native.commentThreads({ action: "list", filter }) reads threads for { kind: "video", videoId } or for every video of a channel ({ kind: "channel" }, the configured channel by default, sent as allThreadsRelatedToChannelId). It passes searchTerms, order (time or relevance), textFormat (html or plainText), moderationStatus (published, heldForReview, or likelySpam; the latter two only for the channel owner), maxResults (1 to 100) and pageToken. includeReplies: true adds the replies part, which carries only some replies inline. To read every reply to a top-level comment, native.commentReplies({ parentId }) calls comments.list with parentId. To fetch comments by ID, native.comments({ commentIds }) calls the same method with id for up to 50 comment or reply IDs (1 unit), with an optional textFormat. social.comments.reply still pages through the video’s threads to confirm the parent belongs to the video. native.heldComments accepts moderationStatus: "likelySpam" to read comments YouTube classified as likely spam instead of those held for review.

Normalized social.comments.list includes imageUrl when a comment has an attached image or animated GIF (snippet.imageUrl, rolling out to all users in November 2026). The URL is a signed link that expires six hours after retrieval. Download the image promptly if you need it, and keep the URL out of logs.

native.commentsModeration with action: "update" calls comments.update to edit a comment the authorized user wrote; send the new snippet.textOriginal.

native.rateVideo calls videos.rate with like, dislike or none, and native.getRating calls videos.getRating for the authorized user’s rating of the listed videos. Since September 1, 2026, getRating also accepts the youtube.readonly scope; rateVideo still needs youtube.force-ssl.

native.reportAbuse calls videos.reportAbuse with videoId, a reasonId, and an optional secondaryReasonId, comments and language. Read valid reason IDs from native.videoAbuseReportReasons (videoAbuseReportReasons.list, 1 unit). A report costs 50 units, returns nothing on success, and raises invalid_input upstream (invalidAbuseReason) for a wrong reason combination.

native.abuseReports({ report }) calls POST /youtube/v3/abuseReports (abuseReports.insert), a newer method that reports any entity rather than only a video. The body follows the discovery document’s AbuseReport schema: a subject entity (id, typeId, url), at least one abuseTypes entry with an id, an optional description, and optional relatedEntities. The adapter sends part as the top-level fields present in the body and returns the created resource. Google publishes no reference page for this method, so valid abuseTypes and typeId values are undocumented; it needs youtube.force-ssl and is not live-verified here. Use native.reportAbuse when a video report is enough.

const thread = await native.commentThreads({
  action: "insert",
  videoId: "v1",
  text: "Thanks for watching",
  context,
});
const spam = await native.commentThreads({
  action: "list",
  filter: { kind: "channel" },
  moderationStatus: "likelySpam",
  context,
});
const replies = await native.commentReplies({ parentId: "Ugz...", maxResults: 100, context });
await native.commentsModeration({
  action: "update",
  commentId: "Ugz...",
  body: { snippet: { textOriginal: "Edited comment" } },
  context,
});
const myRatings = await native.getRating({ videoIds: ["v1", "v2"], context });
const reasons = await native.videoAbuseReportReasons({ context });
await native.reportAbuse({ videoId: "v9", reasonId: "S", context });

native.subscriptions({ action: "list", subscribers: "all" }) lists the configured channel’s subscribers with mySubscribers, and subscribers: "recent" lists them newest first with myRecentSubscribers; both add the subscriberSnippet part and YouTube may cap how many it returns. Lists also accept forChannelIds (sent as forChannelId), order (alphabetical, relevance, unread) and maxResults (0 to 50).

Channel members

native.members and native.membershipsLevels read channel members (members.list) and the channel’s membership levels (membershipsLevels.list). They need the https://www.googleapis.com/auth/youtube.channel-memberships.creator scope and access that a creator requests through a Google or YouTube representative; without it YouTube returns 403. mode: "all_current" (default) lists current members newest first; mode: "updates" returns members who joined or upgraded since the previous call, so the first call returns none and you keep the nextPageToken. filterByMemberChannelIds checks up to 100 channels, hasAccessToLevel filters by level ID, and maxResults is 0 to 1000. A members call costs 2 units.

const members = await native.members({ mode: "all_current", maxResults: 100, context });
const levels = await native.membershipsLevels({ context });

Live broadcasts

native.liveBroadcasts lists (configured channel, selected ID, or a status filter), inserts, updates, deletes, binds, inserts ad cuepoints, and transitions broadcasts to testing, live, or complete. The channel must have live streaming enabled. Stream creation (native.liveStreams) and stream binding (bind) are both available, so a broadcast can go live end to end: create the broadcast, create a stream, bind them, then transition once the encoder is sending video.

  • list calls liveBroadcasts.list with mine=true, an id, or statusFilter (sent as broadcastStatus: active, all, completed, upcoming); YouTube accepts exactly one of the three. broadcastType is all, event or persistent; YouTube defaults to event, so the channel’s persistent (always-on) broadcast is hidden unless you ask for all or persistent. maxResults is 0 to 50 (YouTube’s default page is 5) and pageToken reads the next page.
  • update calls liveBroadcasts.update with the parts present in body (snippet, status, contentDetails, monetizationDetails). YouTube deletes any mutable property a written part leaves out, so send the full part, including snippet.scheduledStartTime and contentDetails.monitorStream. The adapter injects id and rejects a conflicting body ID.
  • delete calls liveBroadcasts.delete with the broadcast id.
  • bind calls liveBroadcasts.bind with streamId; omit streamId to unbind.
  • cuepoint calls liveBroadcasts.cuepoint to insert an ad break (cueType: "cueTypeAd") into a broadcast that is actively streaming. durationSecs defaults to 30 upstream. Set at most one of insertionOffsetTimeMs (works only on a delayed stream) and walltimeMs (epoch milliseconds); both together are rejected locally, as YouTube would answer conflictingTimeFields. Ads only run on channels eligible for monetization.

native.liveStreams manages the ingestion endpoints with liveStreams list, insert, update, and delete. A list without ids returns the caller’s streams (mine=true, which omits non-reusable streams). Inserts need snippet.title (1 to 128 characters) and cdn.ingestionType, cdn.resolution and cdn.frameRate; contentDetails.isReusable keeps a stream for later broadcasts. The response’s cdn.ingestionInfo holds the ingestion address and stream name: treat the stream name as a secret and never log it. Updates select the parts in body; a property YouTube cannot update requires a new stream.

native.superChatEvents calls superChatEvents.list for the Super Chat and Super Sticker purchases on the channel’s live streams in the last 30 days. hl formats the amount for a language; maxResults is 1 to 50.

const broadcast = await native.liveBroadcasts({
  action: "insert",
  body: {
    snippet: { title: "Launch", scheduledStartTime: "2026-12-01T18:00:00Z" },
    status: { privacyStatus: "unlisted" },
  },
  context,
});
const stream = await native.liveStreams({
  action: "insert",
  body: {
    snippet: { title: "Main encoder" },
    cdn: { ingestionType: "rtmp", resolution: "1080p", frameRate: "30fps" },
  },
  context,
});
await native.liveBroadcasts({
  action: "bind",
  id: String(broadcast["id"]),
  streamId: String(stream["id"]),
  context,
});
await native.liveBroadcasts({
  action: "cuepoint",
  id: String(broadcast["id"]),
  cuepoint: { durationSecs: 60 },
  context,
});
const superChats = await native.superChatEvents({ maxResults: 50, context });
const upcoming = await native.liveBroadcasts({
  action: "list",
  statusFilter: "upcoming",
  broadcastType: "all",
  maxResults: 50,
  context,
});

Live chat, channel sections, and activity

The native surface exposes liveChatMessages and liveChatModerators for list, insert, and delete operations, plus liveChatBans for insert and delete; there is no ban list (see Not supported). Supply the live chat ID from a broadcast’s snippet.liveChatId when listing; inserts use the provider resource body’s snippet.liveChatId, and message and moderator deletes require both liveChatId and the message or moderator ID, while ban deletion needs only the ban ID. The YouTube authorization is the ownership boundary for these actions; the adapter validates supplied IDs and body consistency but cannot independently verify chat or ban ownership where the API has no lookup. Message deletion goes directly to YouTube without polling chat; moderator deletion first checks membership in the supplied chat. Message lists accept an integer maxResults from 200 to 2000 and preserve pollingIntervalMillis, which callers must honor before polling again. They also accept hl, which localizes the Super Chat amount strings, and profileImageSize, the author avatar size in pixels from 16 to 720 (YouTube’s default is 88).

native.channelSections lists, inserts, updates, and deletes featured shelves for the configured channel. A channelSections list can also read another channel’s shelves with channelId, and hl returns localized section titles; writes stay limited to the configured channel. Updates replace the writable parts supplied in the body, and YouTube limits a channel to ten sections. native.activities reads activity events for the configured channel; channel bulletins and activities.insert are deprecated and are intentionally unavailable.

const chat = await native.liveChatMessages({
  action: "list",
  liveChatId: "Cg0KC2xpdmVjaGF0…",
  maxResults: 200,
  context,
});
const sections = await native.channelSections({ action: "list", context });
const theirs = await native.channelSections({
  action: "list",
  channelId: "UC_other",
  hl: "es",
  context,
});
await native.liveChatMessages({ action: "closePoll", messageId: "LCC.poll…", context });
const activity = await native.activities({ channelId: channelId, context });

See YouTube’s liveChatMessages, liveChatModerators, liveChatBans, channelSections, and activities.list references for current resource fields and eligibility rules.

Streamed live chat

native.liveChatStream({ liveChatId }) reads chat with liveChatMessages.streamList over HTTP instead of polling. Google’s reference page describes the gRPC interface, but the v3 discovery document (revision 20261103) also lists the method’s REST path, GET /youtube/v3/liveChat/messages/stream. The adapter opens one connection when iteration starts and yields a page (items, nextPageToken, offlineAt, pollingIntervalMillis, activePollItem and the raw response) each time YouTube sends one; messages within a page run oldest to newest. It accepts pageToken, hl and profileImageSize like the list call and requests the id, snippet and authorDetails parts. A connection costs 5 units and needs a youtube, youtube.force-ssl or youtube.readonly scope.

Google publishes no wire framing for the REST form. The adapter parses Google’s standard HTTP/JSON framing for server-streamed methods: one JSON array whose elements arrive as they are produced; a single JSON object is also accepted. An error element that arrives mid-stream raises upstream_failure with its status. This parsing is contract-tested offline only, not against a live chat.

The stream never reconnects. It ends when you stop iterating, context.signal aborts, YouTube closes the response (a page with offlineAt means the chat has gone offline), or the optional stallTimeoutMs passes without data; Google documents no keep-alive interval, so choose a stall timeout longer than a quiet chat’s silence. To resume, open a new stream with the last nextPageToken as pageToken. A response that stops before the closing bracket raises upstream_failure with upstreamCode: "stream-connection-interrupted".

let pageToken: string | undefined;
let offline = false;

// Reconnect after YouTube closes a response, resuming from the last page token.
// Each connection is a new operation, so it gets a fresh context and elapsed budget.
while (!offline) {
  for await (const page of native.liveChatStream({
    liveChatId: "Cg0KC2xpdmVjaGF0…",
    pageToken,
    stallTimeoutMs: 120_000,
    context: { ...context, correlationId: crypto.randomUUID() },
  })) {
    for (const message of page.items) handleChatMessage(message);

    pageToken = page.nextPageToken ?? pageToken;
    offline = page.offlineAt !== undefined;
  }
}

Polls

Polls are created by inserting a pollEvent message body. To end one, native.liveChatMessages({ action: "closePoll", messageId }) calls liveChatMessages.transition (POST /youtube/v3/liveChat/messages/transition) with status=closed and part=snippet, and returns the closed poll message with its final tallies. Closing is the only transition YouTube offers. It costs 20 units and needs the youtube or youtube.force-ssl scope; a message that cannot be closed returns 403 modificationNotAllowed. These methods preserve upstream response bodies and do not claim normalized message or moderation semantics. To receive messages without polling, use streamed live chat.

Raw requests

native.request(input, context) sends one request to a YouTube API endpoint the SDK does not wrap, with the channel’s OAuth token. It checks input.account against the configured channel and context.backendInstance and maps a quotaExceeded 403 to rate_limited.

Hosts: data (the default) is https://www.googleapis.com/youtube/v3, upload is https://www.googleapis.com/upload/youtube/v3, analytics is https://youtubeanalytics.googleapis.com/v2, and reporting is https://youtubereporting.googleapis.com/v1.

// `native` is social.native("default", { acknowledgeUnsafe: true }); `account` is the
// connected account and `context` an AdapterOperationContext for its backend instance.
const response = await native.request(
  {
    account,
    host: "data",
    method: "GET",
    path: "/i18nLanguages",
    query: { part: "snippet", hl: "en" },
  },
  context,
);

if (response.responseType === "json") console.log(response.status, response.body);

path is relative to the host’s base URL and starts with /; put parameters in query. Before any request, the adapter rejects absolute URLs, // paths, .. segments, encoded separators, ? or # in the path, and unknown host names. It also rejects credential and framing headers such as Authorization, Cookie and Content-Type, plus the access_token query parameter. It does not follow redirects. A body has a kind of json, text, bytes, form or urlencoded, and its kind sets Content-Type; text and bytes also take a contentType. Set responseType to "text" or "bytes" for a body that is not JSON. Response bodies are capped at 2 MiB, and maxResponseBytes raises the cap to at most 16 MiB. The result has status, responseType, body and lowercase headers. Name extra headers in responseHeaders; set-cookie is never returned. A non-2xx response rejects with a SocialError mapped like the adapter’s other calls.

A raw request makes one attempt. It adds no idempotency key and records no outcome, so you reconcile the writes you send. A write rejects with ambiguous_outcome when it fails without a definitive answer: a timeout, a cancellation after dispatch, a 5xx, or a 2xx body that cannot be decoded. Like other native calls it skips the client’s tenant authorization, so check the tenant’s grant for the account first. Prefer a typed method when one exists.

Limits and requirements

  • Quota, channel eligibility, scopes, and API audit status remain upstream conditions.
  • Google’s current quota calculator gives videos.insert and search.list separate buckets of 100 calls per day, at 1 unit per call. Other list/get calls generally cost 1 unit from the Data API quota.
  • Video update/delete and playlist writes cost 50 units; schedule cancellation costs 51 including its ownership read. thumbnails.set costs 50; captions list/delete cost 50, insert 400, update 450, and download 200; subscription and comment mutations cost 50. Additional adapter reads consume their own units.
  • Channel upload caps are separate from project quota. uploadLimitExceeded and quotaExceeded raise rate_limited during upload with a sanitized upstream reason. Neither is retried automatically; resolve the cap or quota before starting another write.
  • Upload and resume get at least 15 minutes per invocation. Status probes honor the caller’s remaining elapsed budget. Pass a longer budget for large files and a signal to cancel early.
  • Current evidence is contract coverage, not a live channel verification.

See YouTube’s videos.insert documentation for current audit and quota rules, videos.update and the videos resource for status.publishAt rules, and channels.update for the channel fields you can write.

Media specifications

posts.publish checks the attachment count, the declared MIME type, and that a stream has its exact byte size. The upload session then checks the size against the SDK’s cap before its first request. YouTube checks duration and the file’s contents after the upload, while it processes the video.

Media Requirement YouTube limit Checked by the SDK before sending
Video Per upload One video file Yes: anything other than exactly one video attachment fails as youtube.video.
Video Format video/* or application/octet-stream Declared MIME type must start with video/, else youtube.mime. The file’s bytes are not inspected.
Video File size 256 GB maximum (videos.insert); the Help Center gives 256 GB or 12 hours, whichever is less Yes: a size of 0 or over 256 GiB (274,877,906,944 bytes) fails as invalid_input before the session starts. A stream without its exact byteSize fails as youtube.size.
Video Duration 15 minutes for accounts that have not verified; verified accounts can upload longer videos, up to the 12-hour cap No, YouTube only
Thumbnail Format and size image/jpeg, image/png, or application/octet-stream; 50 MB maximum native.setThumbnail requires a Blob, else invalid_input. Type and size are not checked.
Caption Format and size text/xml, application/octet-stream, or */*; 100 MB maximum native.captions requires a Blob for insert, else invalid_input. Type and size are not checked.

The SDK’s 256 GiB cap is about 7% larger than YouTube’s 256 GB, so a file between the two passes the SDK’s check and can still fail at YouTube.

Sources: YouTube Data API videos.insert, thumbnails.set, and captions.insert (each last updated November 3, 2026), and YouTube Help’s Upload videos longer than 15 minutes, checked 2026-11-04.

Not supported

  • Content-owner delegation: onBehalfOfContentOwner, onBehalfOfContentOwnerChannel, search’s forContentOwner filter and channels.list’s managedByMe filter are for YouTube content partners whose CMS account is linked to a content owner. The adapter authorizes one channel with that channel’s own OAuth token and sends none of them on any method.
  • thirdPartyLinks (list, insert, update, delete for channelToStoreLink and channelToAffiliateProgramLink): the discovery document exposes it, but Google publishes no reference page and it serves YouTube Shopping partners linking a channel to a store or affiliate program. The SDK does not wrap it.
  • comments.markAsSpam: deprecated in the revision history (2023-09-12) and removed from the comments reference, although it remains in the discovery document. Use native.commentsModeration with action: "setModerationStatus" to hold or reject a comment instead.
  • Live chat ban lists: YouTube does not expose a liveChatBans.list or read method, so the adapter can insert and delete bans but not list them.

Last updated on November 9, 2026