# Videos & Shorts

Publish a video or a Short to YouTube, add it to a playlist, schedule it, edit a published video, and every platformSpecificData field.

import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

A plain post becomes a public video. Its title is the first line of `content`, and its description is the whole of `content`.

## Video

Long-form content: longer than 3 minutes or horizontal.

YouTube classifies a video as a Short on its own when it is 3 minutes or shorter and vertical or square.

16:9 is the recommended aspect ratio. A `thumbnail` on the media item sets the custom cover.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data: published } = await zernio.posts.createPost({
  body: {
    content: 'In this tutorial, I walk through building a REST API from scratch.\n\n#programming #tutorial',
    tags: ['rest api', 'node.js', 'backend tutorial'],
    mediaItems: [{
      type: 'video',
      url: 'https://cdn.example.com/long-form-video.mp4',
      thumbnail: 'https://cdn.example.com/thumbnail.jpg'
    }],
    platforms: [{
      platform: 'youtube',
      accountId: '66b2e19d8c3f5a7e9d0b1c2d',
      platformSpecificData: {
        title: 'Build a REST API from scratch',
        visibility: 'public',
        categoryId: '27',
        madeForKids: false
      }
    }],
    publishNow: true
  }
});

console.log(published.post.platforms[0].platformPostUrl);
```
</Tab>
<Tab value="Python">
```python
from zernio import Zernio

client = Zernio()

published = client.posts.create_post(
    content="In this tutorial, I walk through building a REST API from scratch.\n\n#programming #tutorial",
    tags=["rest api", "node.js", "backend tutorial"],
    media_items=[{
        "type": "video",
        "url": "https://cdn.example.com/long-form-video.mp4",
        "thumbnail": "https://cdn.example.com/thumbnail.jpg"
    }],
    platforms=[{
        "platform": "youtube",
        "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
        "platformSpecificData": {
            "title": "Build a REST API from scratch",
            "visibility": "public",
            "categoryId": "27",
            "madeForKids": False
        }
    }],
    publish_now=True
)

print(published["post"]["platforms"][0]["platformPostUrl"])
```
</Tab>
<Tab value="curl">
```bash
curl -X POST https://zernio.com/api/v1/posts \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "In this tutorial, I walk through building a REST API from scratch.\n\n#programming #tutorial",
    "tags": ["rest api", "node.js", "backend tutorial"],
    "mediaItems": [{
      "type": "video",
      "url": "https://cdn.example.com/long-form-video.mp4",
      "thumbnail": "https://cdn.example.com/thumbnail.jpg"
    }],
    "platforms": [{
      "platform": "youtube",
      "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
      "platformSpecificData": {
        "title": "Build a REST API from scratch",
        "visibility": "public",
        "categoryId": "27",
        "madeForKids": false
      }
    }],
    "publishNow": true
  }'
```
</Tab>
</Tabs>

Response (`201`):

```json
{
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "status": "published",
    "platforms": [
      {
        "platform": "youtube",
        "status": "published",
        "platformPostUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
      }
    ]
  }
}
```

`tags` sits at the top level of the request, alongside `content`. It reaches YouTube as `snippet.tags` on the upload.

Zernio cleans the list before upload:

1. Strips a leading `#`.
2. Splits a comma-joined entry into separate tags.
3. Drops duplicates.
4. Keeps tags in order until the combined length would pass 500 characters.

A single tag longer than 100 characters is skipped.

Every sample below changes only the `platforms` entry of this request.

## Shorts

YouTube detects Shorts on its own: a video that is 3 minutes or shorter and vertical or square (9:16 or 1:1) is classified as a Short.

There is no post type or flag to set. The request is the one above with a short vertical video.

Videos under 15 seconds loop automatically. A `thumbnail` on the video media item sets a custom thumbnail on a Short too (verified on 2026-11-01).

## Playlists

Create a playlist with `POST /v1/accounts/{accountId}/youtube-playlists` ([Create YouTube playlist](/connect/create-youtube-playlist)).

Only `title` is required. `privacy` defaults to `private` and also accepts `public` or `unlisted`.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data: created } = await zernio.connect.createYoutubePlaylist({
  path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d' },
  body: { title: 'Tutorials', description: 'Step-by-step video tutorials', privacy: 'private' }
});

console.log(created.playlist.id);
```
</Tab>
<Tab value="Python">
```python
created = client.connect.create_youtube_playlist(
    account_id="66b2e19d8c3f5a7e9d0b1c2d",
    title="Tutorials",
    description="Step-by-step video tutorials",
    privacy="private"
)

print(created["playlist"]["id"])
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/youtube-playlists" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Tutorials","description":"Step-by-step video tutorials","privacy":"private"}'
```
</Tab>
</Tabs>

Response (`201`):

```json
{
  "playlist": {
    "id": "PLxxxxxxxxxxxxx",
    "title": "Tutorials",
    "description": "Step-by-step video tutorials",
    "privacy": "private",
    "itemCount": 0,
    "thumbnailUrl": ""
  }
}
```

Creation costs 50 YouTube quota units and requires the `youtube` or `youtube.force-ssl` OAuth scope.

The playlist starts empty and does not become the account's default. Use the returned `playlist.id` in `platformSpecificData.playlistId` when publishing a video.

<Callout type="warn">
Playlist creation is not idempotent. Repeating a request can create another playlist, including after a timeout. Zernio does not automatically retry creation; list the channel's playlists before retrying an ambiguous failure.
</Callout>

**Official series.** YouTube's public API does not expose the **official series** setting. After creating a playlist, enable **Set as official series for this playlist** manually in YouTube's desktop playlist settings.

See [YouTube's series playlist requirements](https://support.google.com/youtube/answer/6084043).

`playlistId` adds the video to an existing playlist after upload, for immediate and scheduled uploads alike. Without it, the video is uploaded normally.

List the channel's playlists with `GET /v1/accounts/{accountId}/youtube-playlists` ([List YouTube playlists](/connect/get-youtube-playlists)):

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data: playlists } = await zernio.connect.getYoutubePlaylists({
  path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d' }
});

console.log(playlists.playlists);
```
</Tab>
<Tab value="Python">
```python
playlists = client.connect.get_youtube_playlists(
    account_id="66b2e19d8c3f5a7e9d0b1c2d"
)

print(playlists["playlists"])
```
</Tab>
<Tab value="curl">
```bash
curl "https://zernio.com/api/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/youtube-playlists" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```
</Tab>
</Tabs>

Response (`200`):

```json
{
  "playlists": [
    {
      "id": "PLxxxxxxxxxxxxx",
      "title": "Tutorials",
      "privacy": "public",
      "itemCount": 12
    }
  ],
  "defaultPlaylistId": null
}
```

Then pass the id when creating the post:

```json
{
  "platform": "youtube",
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "platformSpecificData": {
    "title": "Build a REST API from scratch",
    "visibility": "public",
    "playlistId": "PLxxxxxxxxxxxxx"
  }
}
```

**Default playlist.** `PUT /v1/accounts/{accountId}/youtube-playlists` ([Set default YouTube playlist](/connect/update-youtube-default-playlist)) stores a default playlist on the account to prefill your own UI.

It does not apply to posts that omit `playlistId`:

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data: saved } = await zernio.connect.updateYoutubeDefaultPlaylist({
  path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d' },
  body: { defaultPlaylistId: 'PLxxxxxxxxxxxxx', defaultPlaylistName: 'Tutorials' }
});

console.log(saved.success);
```
</Tab>
<Tab value="Python">
```python
saved = client.connect.update_youtube_default_playlist(
    account_id="66b2e19d8c3f5a7e9d0b1c2d",
    default_playlist_id="PLxxxxxxxxxxxxx",
    default_playlist_name="Tutorials"
)

print(saved["success"])
```
</Tab>
<Tab value="curl">
```bash
curl -X PUT https://zernio.com/api/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/youtube-playlists \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "defaultPlaylistId": "PLxxxxxxxxxxxxx",
    "defaultPlaylistName": "Tutorials"
  }'
```
</Tab>
</Tabs>

Response (`200`):

```json
{ "success": true }
```

## Scheduling

A post with `scheduledFor` in the future runs in this order:

1. Zernio uploads the video ahead of its scheduled time, so YouTube has finished processing it before the video goes live.
2. A video targeting `"public"` goes up as `"private"` and carries YouTube's own `publishAt`, so YouTube releases it at the scheduled second. A video targeting `"private"` or `"unlisted"` is uploaded with that visibility and never changes.
3. A video URL exists as soon as the upload finishes, but the video is not publicly accessible before the release.
4. `firstComment` is posted at the scheduled time, not at upload time.

```json
{
  "platform": "youtube",
  "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
  "scheduledFor": "2027-01-01T12:00:00",
  "platformSpecificData": {
    "title": "Build a REST API from scratch",
    "visibility": "public",
    "firstComment": "Chapters and source code are in the description."
  }
}
```

Set `timezone` on the request so the scheduled time is read in the right zone; see [post lifecycle](/guides/post-lifecycle).

## Edit a published video

[Edit post](/posts/edit-post) replaces the video description only.

There is no time window and no limit on the number of edits. The video id does not change, so the video keeps its URL.

The title is left exactly as it was, including when it was derived from the first line of `content` at publish time.

**Title, tags, thumbnail and visibility** changes go through [Update post metadata](/posts/update-post-metadata). It also works on videos uploaded outside Zernio when you pass `videoId` and `accountId` with `_` as the post id.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data: edited } = await zernio.posts.editPost({
  path: { postId: '65f1c0a9e2b5af0012ab34cd' },
  body: {
    platform: 'youtube',
    content: 'Updated description with corrected chapter timestamps.'
  }
});

console.log(edited.url);
```
</Tab>
<Tab value="Python">
```python
edited = client.posts.edit_post(
    post_id="65f1c0a9e2b5af0012ab34cd",
    platform="youtube",
    content="Updated description with corrected chapter timestamps."
)

print(edited["url"])
```
</Tab>
<Tab value="curl">
```bash
curl -X POST https://zernio.com/api/v1/posts/65f1c0a9e2b5af0012ab34cd/edit \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "youtube",
    "content": "Updated description with corrected chapter timestamps."
  }'
```
</Tab>
</Tabs>

Response (`200`):

```json
{
  "success": true,
  "id": "dQw4w9WgXcQ",
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "message": "youtube post edited successfully"
}
```

The new description is sanitized before it is written:

- angle brackets (`<` and `>`) are stripped
- anything past 5,000 characters is truncated

Neither one fails the call. A request that hits either rule still returns success with the video id and URL.

**Several channels.** If the post was published to several YouTube channels, pass `accountId` to pick which copy to edit. Without it, the first `youtube` entry on the post is edited.

## Platform fields

All fields go in `platformSpecificData` on the YouTube entry.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `title` | string | First line of `content`, or `"Untitled Video"` | Video title, at most 100 characters. |
| `visibility` | `"public"` \| `"private"` \| `"unlisted"` | `"public"` | Who can see the video. |
| `madeForKids` | boolean | `false` | Marks the video as child-directed for COPPA. `true` permanently disables comments, the notification bell, personalized ads, end screens and cards on the video. YouTube may block views when the flag is never set. |
| `containsSyntheticMedia` | boolean | `false` | Discloses that the video contains synthetic content that could be mistaken for real. YouTube may add a label to the video. |
| `categoryId` | string | `"22"` (People & Blogs) | Video category. Common values: `"1"` Film, `"10"` Music, `"20"` Gaming, `"22"` People & Blogs, `"27"` Education, `"28"` Science & Technology. |
| `playlistId` | string | | Playlist to add the video to after upload. See [Playlists](#playlists). |
| `firstComment` | string | | Posted and pinned as the first comment, at most 10,000 characters. Posted immediately with `publishNow`, at the scheduled time otherwise. |