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

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. **Configure Google OAuth**

    Request `https://www.googleapis.com/auth/youtube.upload` when the connected workflow uploads
    videos. Add `https://www.googleapis.com/auth/youtube.force-ssl` for captions and playlist
    mutations, `https://www.googleapis.com/auth/youtube` to cancel a scheduled video, and
    `https://www.googleapis.com/auth/yt-analytics.readonly` for Analytics API reports. Refresh with
    `refreshOAuthToken("youtube", ...)` and persist any rotated refresh token.

2. **Store credentials server-side**

    Set `YOUTUBE_ACCESS_TOKEN` and `YOUTUBE_CHANNEL_ID` in server configuration. Upload bytes and
    resumable session URIs are secrets too.

3. **Configure the adapter**

    Construct the backend and select the channel through a connected-account reference.

```ts
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!,
});
```

## Examples

### Upload a video

Preparation requires exactly one video, an actual `video/*` MIME type, a title from 1–100 characters, explicit `visibility`, and an explicit `madeForKids` value.

```ts
const media = {
  kind: "video" as const,
  source: { kind: "blob" as const, blob, fingerprint: contentHash },
  mimeType: "video/mp4",
  byteSize: blob.size,
};

const result = await social.posts.publish({
  targets: [
    {
      account,
      options: { title: "A release", visibility: "private", madeForKids: 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.

```ts
const result = await social.posts.publish({
  targets: [
    {
      account,
      options: { title: "A release", visibility: "private", madeForKids: false },
    },
  ],
  content: { text: "Description", media: [media] },
  schedule: { at: "2026-10-01T15:00:00Z" },
});
```

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.

```ts
const outcome = result.outcomes[0];
if (outcome?.state === "scheduled") {
  await social.posts.cancelScheduled(outcome.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.

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

### Resume an interrupted upload

Resumable upload sessions can be persisted with `saveUploadSession`. A non-complete upload is `unknown` and must be resumed or reconciled explicitly; the adapter does not resume in the background.

### 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](/events).

## Native operations

Provider-shaped operations are available on `social.native`:

```ts
const native = social.native("default", { acknowledgeUnsafe: true });
await native.playlists({ action: "list", mine: true, context });
await native.playlistItems({
  action: "insert",
  playlistId: "PL...",
  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`.

```ts
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](https://developers.google.com/youtube/v3/docs/channels/update#errors) 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. Changing the banner takes a [`channelBanners.insert`](https://developers.google.com/youtube/v3/docs/channelBanners/insert) upload followed by a `channels.update` that sets `brandingSettings.image.bannerExternalUrl`, which `updateProfile` does not support. The call needs `https://www.googleapis.com/auth/youtube` and costs 51 quota units: 1 for the read and 50 for the write.

`updateVideo` fetches the current `snippet` and `status` first, then sends the complete resource required by `videos.update`. Caption downloads use `action: "download"`; insert and update require explicit metadata and media. Held-for-review threads use `native.heldComments`. `deleteComment` calls [`comments.delete`](https://developers.google.com/youtube/v3/docs/comments/delete) with `youtube.force-ssl` and costs 50 quota units. Pass a top-level comment ID; comment threads have no delete method. Google does not document which comments a channel may delete, and a 403 raises `missing_permission`. To remove another user's comment from your video, use `commentsModeration` with `moderationStatus: "rejected"`. The normalized `social.search.posts(account, { query })` maps to public `search.list` results with `type=video`; YouTube accepts only the `recent` scope and limits pages to 50 items. Use `native.search` for provider filters such as `channelId`, `order`, and date bounds.

## Limits and requirements

:::warning
Unverified Google projects may be restricted to private uploads. The adapter does not turn a private upload into public visibility.
:::

- Quota, channel eligibility, scopes, and API audit status remain upstream conditions.
- Data API quota costs are method-specific: list/search/get operations generally cost 1 unit; playlist and video mutations cost 50, so schedule cancellation costs 51 including its read; `thumbnails.set` costs approximately 50; captions list/delete cost 50, insert 400, update 450, and download 200; subscriptions mutations cost 50; comment moderation mutations cost 50. `search.list` is also subject to Google's documented 100-calls-per-day Search Queries limit.
- Playlist and video management generally uses `youtube` or `youtube.readonly`; ratings and captions use `youtube.force-ssl`; uploads use `youtube.upload`. Google OAuth verification, channel ownership, and project quota remain approval or account conditions.
- Video uploads get at least 15 minutes, even when `retryBudget.maxElapsedMs` is shorter, because the default 30-second budget cannot fit a real upload. Pass a longer budget for very large files, and pass `signal` to cancel an upload early.
- Current evidence is contract coverage, not a live channel verification.

See YouTube's [videos.insert](https://developers.google.com/youtube/v3/docs/videos/insert) documentation for current audit and quota rules, [videos.update](https://developers.google.com/youtube/v3/docs/videos/update) and the [videos resource](https://developers.google.com/youtube/v3/docs/videos#status.publishAt) for `status.publishAt` rules, and [channels.update](https://developers.google.com/youtube/v3/docs/channels/update) for the channel fields you can write.
