# YouTube Channel Videos List (`zinin/youtube-channel-videos-list`) Actor

List up to 100 most recent uploads for any YouTube channel: videoId, title, view count, and publish age for the first 30. No API key, no login — just the channel handle, URL, or id.

- **URL**: https://apify.com/zinin/youtube-channel-videos-list.md
- **Developed by:** [Tim Zinin](https://apify.com/zinin) (community)
- **Categories:** Social media, Videos
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.85 / 1,000 video record delivereds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## YouTube Channel Videos List: Up to 100 Recent Uploads Per Channel, No API Key

Give this Actor a YouTube channel — handle, URL, or bare channel ID — and it returns up to 100 of that
channel's most recent uploaded videos: video ID, title, view count, and, for the newest 30, publish age.
No API key, no login, no quota to manage, no browser to drive.

![How this Actor turns your input into a decision](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/youtube-channel-videos-list/readme-hero.webp)
The billable outcome is a **delivered video row**: one entry in the dataset with a real `videoId` from
the requested channel's own upload history, whether or not that row also carries publish age or a
title (both can legitimately be absent — see Real happy, partial and failure output). A channel that
cannot be resolved, or that genuinely has zero uploads, produces one free summary row instead and is
never charged.

### What you get

- **Up to 100 of a channel's most recent uploads, read from the channel's own uploads playlist —
  not the 30 the `/videos` tab shows.** A live measurement on `@mkbhd` found the uploads playlist
  carrying 100 `videoId`s against 30 on the `/videos` tab, and 9 of the playlist's first 30 videos
  (Shorts the `/videos` tab quietly filters out) were missing from that shorter list entirely. This
  Actor reads the playlist, not the tab, specifically so Shorts are not silently dropped from your
  count.
- **Publish age for the newest 30 positions, sourced primarily from the same page as the video list
  itself** — no second request needed in the common case. A live full run on `@mkbhd` on 2026-08-17
  delivered `ageText` on all 30 of those positions from the uploads-playlist page alone; a separate
  `/videos`-tab request only fires as a fallback when the playlist page itself leaves a gap in that
  window, and is stitched back in by `videoId`, never by row position (two different YouTube renders,
  no guaranteed matching order).
- **View counts as YouTube's own rounded display text** — `"2.9M views"`, `"7M views"` — because that
  is what the source publishes on these pages. This Actor never invents a precise number where YouTube
  itself shows a rounded one; the field is documented as display text, not exact data, everywhere it
  appears on this page.
- **A flat, honest row for every outcome**, never a bare empty result. A channel that cannot be found,
  a channel that genuinely has zero uploads, and a request blocked by the host-safety guard each
  produce one clearly labelled summary row with a real `error` string — never a silent zero-row dataset
  that looks the same as "this Actor is broken."
- **No purchase of what this Actor cannot honestly deliver.** Transcripts and comments are not in the
  output, and never will be from this specific request pattern — `robots.txt` on `www.youtube.com`
  disallows the `/api/`, `/youtubei/`, `/results` and `/comment` paths those features require, and this
  Actor only ever requests `/@handle/about`, `/@handle/videos` and `/playlist`, none of which are
  disallowed. That boundary is checked below in Evidence and boundaries, not assumed.
- **A channel supplied as a bare `UC…` id skips one full request.** This Actor's own resolution step
  only fetches the channel's `/about` page to discover its channel ID when you give it a handle or a
  URL; a bare channel ID is already the identity it needs, so that step — and its ~2.5 MB page fetch —
  is skipped entirely.

### Who uses it

- **Content marketers and analysts tracking a competitor or a partner channel's upload cadence** —
  how often does this channel publish, and on what titles — without refreshing the channel's own
  `/videos` tab by hand or paying for a full "channel intel" product when all that is needed is the
  video list itself.
- **Media-monitoring and PR teams** building a recurring digest of a set of channels' latest uploads,
  feeding a shared content-review queue or a weekly brief.
- **Agencies running channel audits for clients**, pulling a clean, structured snapshot of a channel's
  most recent output — title, view count, publish recency — as a starting point for a content strategy
  review.
- **Developers and workflow builders** who want a flat, machine-checkable video feed — `status`,
  `partial`, `action` — that an n8n node, a Make scenario, or an agent can branch on directly, without
  handling YouTube's own Data API quota, OAuth, or the HTML shape of the page itself.
- **A confirmed, separate demand signal from "channel intel."** This Actor's own market research found
  a comparable channel-video-listing product in this specific sub-niche serving several hundred monthly
  users on this platform — a distinct buyer intent from "find me this channel's contact email," which
  is what a channel-intel product answers instead. If you also need subscriber counts, business email,
  or external links from a channel's About page, that is a different Actor's job — see Evidence and
  boundaries.

### How to run

1. Provide `channels` — one or more YouTube channels as `@handle`, a full channel URL
   (`https://www.youtube.com/@handle`), or a bare channel ID (`UC…`), up to 50 per run. The prefilled
   `["@mkbhd"]` needs no configuration and returns a real, non-empty 100-row dataset on the default
   input — this is not a placeholder that fails silently, it is a channel checked live before this page
   was written.
2. Leave `maxConcurrency` at its default of 5 unless you have a specific reason to raise it — this
   Actor's own stability has only been verified against sequential and moderately parallel requests, not
   a sustained high-concurrency load against YouTube. See Operating guide.
3. Start the run from the Console, the Apify API, a scheduled Task, or an MCP client.
4. Read the Dataset for the result: one row per video (up to 100 per requested channel), or exactly one
   summary row per channel that could not be resolved or returned no uploads at all.
5. Check `status` and `partial` before treating a channel's rows as a complete picture. A `partial:true`
   video row is still a real, billed video — it is only missing one specific thing (publish age beyond
   position 30, an occasionally-absent title, or the channel simply had fewer than 100 uploads to
   return) — while `status:"empty"` or `status:"error"` on a channel's one summary row means that
   channel produced no video rows at all this run, and was not charged.
6. This Actor does not maintain a baseline between runs. Each run is a fresh snapshot of a channel's
   current upload history; detecting "what's new since last time" is a downstream job — see Integration
   recipes for a diffing pattern built on `videoId`.
7. Check this run's key-value store record `OUTPUT` for a one-time run summary — requested/delivered/
   paid/free/failed/incomplete counts, replay safety and any fatal error — written once at the end of
   the run, alongside the Dataset rows.

### Pricing

**$0.005 Actor start, plus $0.001 per delivered video row.** The primary pricing noun is the
**delivered video row**: one dataset row carrying a real `videoId` from the requested channel's own
upload history, with `status:"ok"` or `status:"partial"` — a full row is billed the same as a
`partial:true` row, because both carry a genuine video identity, just not every optional field.

Every other row is free: a channel that cannot be resolved (`status:"error"`), a channel confirmed to
have zero uploads (`status:"empty"`), and any explanatory row this Actor writes when a run's own spend
cap is reached mid-way through a channel list. A run against the default `["@mkbhd"]` input — the
channel this page's own examples were pulled from — delivers 100 video rows and is billed
$0.005 + (100 × $0.001) = **$0.105** for that one channel; a run against ten channels that each return a
full 100 videos costs the same $0.001 per row, ten times over, plus one Actor start.

This price sits in the same corridor as this category's raw-content-feed comparables (a few tenths of a
cent per result) rather than the higher per-contact pricing a "find me an email address" lead-gen
Actor commands — because that is the honest shape of what this Actor delivers: a channel's own public
upload list, not a contact.

On Apify's own billing record these are metered as two separate platform events — `apify-actor-start`
for the run fee, and `result-found` for each delivered video row — the same two lines you will see on
your invoice.

### Input contract

```json
{
  "channels": ["@mkbhd"],
  "maxConcurrency": 1
}
```

This is the exact input this page's own live examples below were produced from — not an illustration.

```json
{
  "channels": ["https://www.youtube.com/@ycombinator", "UCBJycsmduvYEL83R_U4JriQ"],
  "maxConcurrency": 10
}
```

*(This second example is a contract illustration built from `.actor/input_schema.json` — a full URL
form alongside a bare channel ID, at a higher concurrency setting — not itself the output of a
specific run; an input has no result of its own to reproduce.)*

- **`channels`** (array of strings, required, up to 50 items). Accepts three interchangeable forms:

  - `@handle` — e.g. `"@mkbhd"`.
  - A full YouTube channel URL — `https://www.youtube.com/@handle`, `.../channel/UC…`, or the legacy
    `.../c/Name` / `.../user/Name` forms.
  - A bare channel ID — `UC` followed by 22 characters, e.g. `"UCBJycsmduvYEL83R_U4JriQ"` (`@mkbhd`'s
    own, confirmed live). Supplying this form directly skips the `/about` resolution request entirely.

  You never need to know or supply the channel's technical **uploads playlist** id (`UU…`) — this Actor
  derives it itself from the resolved channel ID by slicing off the leading `UC` and replacing it with
  `UU`, confirmed correct on this page's own live run (see Real happy, partial and failure output for
  the exact regression case). A value that looks like a URL but does not resolve to
  `youtube.com`/`www.youtube.com`/`m.youtube.com` is rejected before any network request is made — see
  the SSRF-guard example below — and a bare value containing `/`, `..`, or `://` is rejected the same
  way, before it is ever substituted into a request URL.
- **`maxConcurrency`** (integer, default `5`, range 1–20). How many channels this run processes at
  once. The conservative default reflects what has actually been verified live — sequential and
  moderate parallelism — not a claim about a tested ceiling; see Operating guide before raising it for
  a large channel list.

### Real happy, partial and failure output

Every row shares one flat, 16-field schema regardless of outcome, matching `.actor/dataset_schema.json`
exactly. Nothing below is invented — every JSON object on this page was pulled directly from this
Actor's own local runs against the exact input shown, immediately before this page was written. Video
titles, view counts and channel IDs are `@mkbhd`'s own public data, unmodified.

#### Happy path — a video inside the age window (position 1 of 100)

```json
{
  "requestedChannel": "@mkbhd",
  "channelId": "UCBJycsmduvYEL83R_U4JriQ",
  "canonicalChannelUrl": "http://www.youtube.com/@mkbhd",
  "uploadsPlaylistId": "UUBJycsmduvYEL83R_U4JriQ",
  "videoId": "o4SSoURPODY",
  "position": 1,
  "title": "Google Pixel 11/Pro/Fold Impressions: It Is What It Is",
  "viewCountText": "2.9M views",
  "ageText": "5 days ago",
  "source": "uploads-playlist",
  "status": "ok",
  "confidence": "display-text",
  "partial": false,
  "action": "ingest",
  "error": null,
  "checkedAt": "2026-08-17T20:21:35.818Z"
}
```

`canonicalChannelUrl` is quoted exactly as YouTube's own page returned it — note the `http://` scheme,
not `https://`. This Actor passes that field through unmodified rather than "fixing" it, because it is
the source's own value, not this Actor's construction.

#### Boundary row — the last position still inside the age window (position 30 of 100)

```json
{
  "requestedChannel": "@mkbhd",
  "channelId": "UCBJycsmduvYEL83R_U4JriQ",
  "canonicalChannelUrl": "http://www.youtube.com/@mkbhd",
  "uploadsPlaylistId": "UUBJycsmduvYEL83R_U4JriQ",
  "videoId": "Hc0aqOEU2w8",
  "position": 30,
  "title": "Bluey Phone Review",
  "viewCountText": "4M views",
  "ageText": "4 months ago",
  "source": "uploads-playlist",
  "status": "ok",
  "confidence": "display-text",
  "partial": false,
  "action": "ingest",
  "error": null,
  "checkedAt": "2026-08-17T20:21:35.819Z"
}
```

On this same live run, every one of positions 1 through 30 carried a non-empty `ageText` sourced
directly from the uploads-playlist page itself — no `/videos`-tab fallback request was needed for this
channel on this run, and `source` reads `"uploads-playlist"` on all 100 rows as a result. The
`"uploads-playlist+videos-crossref"` source value exists in this Actor's code for the case where the
playlist page itself leaves a gap in the age window (see Evidence and boundaries), but did not fire on
this specific run.

#### Partial path — a video outside the published age window (position 31 of 100)

```json
{
  "requestedChannel": "@mkbhd",
  "channelId": "UCBJycsmduvYEL83R_U4JriQ",
  "canonicalChannelUrl": "http://www.youtube.com/@mkbhd",
  "uploadsPlaylistId": "UUBJycsmduvYEL83R_U4JriQ",
  "videoId": "eFUB_jL_XcM",
  "position": 31,
  "title": "So This is Peak Foldable",
  "viewCountText": "6.6M views",
  "ageText": null,
  "source": "uploads-playlist",
  "status": "partial",
  "confidence": "display-text",
  "partial": true,
  "action": "flag-no-age",
  "error": null,
  "checkedAt": "2026-08-17T20:21:35.819Z"
}
```

This row is billed the same as a full happy-path row — it carries a real `videoId`, `title` and
`viewCountText`. `ageText:null` here is a fixed, published contract boundary (position > 30), not a
source failure; `partial:true` and `action:"flag-no-age"` name the reason honestly rather than leaving
it to be inferred. The last row of this same run, position 100, carries the identical shape
(`"HP_mXUuHPbo"`, `"Samsung broke this"`, `"7M views"`, `ageText:null`, `status:"partial"`) — the
partial shape holds all the way to the ceiling, not just near the boundary.

#### Silent-empty path — a syntactically valid but non-existent uploads playlist

```json
{
  "requestedChannel": "UC0000000000000000000000",
  "channelId": "UC0000000000000000000000",
  "canonicalChannelUrl": "https://www.youtube.com/channel/UC0000000000000000000000",
  "uploadsPlaylistId": "UU0000000000000000000000",
  "videoId": null,
  "position": null,
  "title": null,
  "viewCountText": null,
  "ageText": null,
  "source": "uploads-playlist",
  "status": "empty",
  "confidence": null,
  "partial": false,
  "action": "skip-not-found",
  "error": null,
  "checkedAt": "2026-08-17T20:18:10.546Z"
}
```

This was live-checked directly against `youtube.com/playlist?list=UU0000000000000000000000`: a genuine
`HTTP 200` response carrying zero video entries, not an error page and not a timeout. `error` is `null`
here — not a message — specifically because nothing went wrong; the source answered and the honest
answer was "no uploads under this identity." This is the same request pattern this Actor uses for every
real channel, run against a channel ID that does not correspond to any real uploader; it confirms the
uploads-playlist endpoint's silent-empty behavior on this Actor's own `UU`-derived id namespace
specifically, closing a verification gap this build's own spec had flagged as open before this page was
written. It does not, on its own, prove the same shape for a real, human-registered channel that
genuinely uploaded zero videos — that is a narrower claim this run does not make; see Limits.

#### Failure path — a channel handle that does not resolve

```json
{
  "requestedChannel": "@this-handle-should-not-exist-zzz123",
  "channelId": null,
  "canonicalChannelUrl": null,
  "uploadsPlaylistId": null,
  "videoId": null,
  "position": null,
  "title": null,
  "viewCountText": null,
  "ageText": null,
  "source": "channel-resolution",
  "status": "error",
  "confidence": null,
  "partial": false,
  "action": "skip-not-found",
  "error": "channel not found (HTTP 404)",
  "checkedAt": "2026-08-17T20:17:30.607Z"
}
```

YouTube's own `/about` page for a handle that does not exist returns a genuine `HTTP 404` — this Actor
reports that status verbatim in `error` rather than composing its own generic message, so a buyer
reading the row can tell exactly what the source itself said. This row was free — no charge, because no
video identity was ever established for it.

#### Guard path — a request blocked before it ever reaches the network

```json
{
  "requestedChannel": "https://evil.example/@fake",
  "channelId": null,
  "canonicalChannelUrl": null,
  "uploadsPlaylistId": null,
  "videoId": null,
  "position": null,
  "title": null,
  "viewCountText": null,
  "ageText": null,
  "source": "channel-resolution",
  "status": "error",
  "confidence": null,
  "partial": false,
  "action": "skip-not-found",
  "error": "blocked host (not a youtube.com URL): evil.example",
  "checkedAt": "2026-08-17T20:17:30.316Z"
}
```

`evil.example` was never contacted. This Actor's input normalizer checks any URL-shaped `channels`
value against an explicit allow-list of YouTube hosts (`youtube.com`, `www.youtube.com`,
`m.youtube.com`) before it is substituted into any request template — a channel value that fails this
check produces this exact row and no outbound request at all, live-confirmed on this run.

#### Regression confirmation — the uploads-playlist id is a slice, never a find-and-replace

The same run that produced the silent-empty example above also carried `channels: ["UC0000...", 
"UCUCxxxxxxxxxxxxxxxxxxxx"]` — the second value engineered to contain the literal substring `"UC"`
a second time, inside its own tail. Its resulting row:

```json
{
  "requestedChannel": "UCUCxxxxxxxxxxxxxxxxxxxx",
  "channelId": "UCUCxxxxxxxxxxxxxxxxxxxx",
  "canonicalChannelUrl": "https://www.youtube.com/channel/UCUCxxxxxxxxxxxxxxxxxxxx",
  "uploadsPlaylistId": "UUUCxxxxxxxxxxxxxxxxxxxx",
  "videoId": null,
  "position": null,
  "title": null,
  "viewCountText": null,
  "ageText": null,
  "source": "uploads-playlist",
  "status": "empty",
  "confidence": null,
  "partial": false,
  "action": "skip-not-found",
  "error": null,
  "checkedAt": "2026-08-17T20:18:10.513Z"
}
```

`uploadsPlaylistId` reads `"UUUCxxxxxxxxxxxxxxxxxxxx"` — only the leading two characters were replaced.
A naive global find-and-replace of `"UC"` with `"UU"` would have corrupted the embedded `"UC"` further
into the id as well, producing a wrong, unrecoverable playlist id on any real channel whose id happens
to contain that two-letter sequence again after position 2. This Actor slices the first two characters
off instead — confirmed correct on this live run, not just by inspection of the code.

### Field dictionary

| Field | Meaning | Important boundary |
|---|---|---|
| `requestedChannel` | The literal value you supplied in `channels` | Always populated, on every row shape |
| `channelId` | The resolved `UC…` channel ID | `null` only when resolution never completed (handle 404, SSRF block, invalid input) |
| `canonicalChannelUrl` | YouTube's own canonical URL for the channel | Passed through exactly as the source returns it, including its `http://` scheme — never rewritten |
| `uploadsPlaylistId` | This channel's uploads playlist id, `"UU" + channelId.slice(2)` | Computed by character slice, never string replace — see the regression example above |
| `videoId` | The video's own 11-character YouTube id | `null` on every channel-level summary row (error/empty) |
| `position` | 1-based rank in the uploads playlist as returned, 1–100 | `null` on summary rows; not guaranteed stable between runs if the channel uploads or deletes videos between them |
| `title` | The video's title | Nullable — the uploads-playlist page occasionally omits it for a specific video even at `HTTP 200`; a missing title never demotes the row to `status:"error"` |
| `viewCountText` | YouTube's own rounded display text for view count | Never an exact integer — `"2.9M views"`, `"7M views"` are the source's own strings, not this Actor's rounding |
| `ageText` | YouTube's own rounded display text for publish recency | Populated only for positions 1–30; `null` for every position beyond, by design, not by source failure |
| `source` | Where this row's data came from | `"uploads-playlist"` (the common case), `"uploads-playlist+videos-crossref"` (fallback age enrichment fired), or `"channel-resolution"` (a summary row produced before the playlist was ever fetched) |
| `status` | The outcome of this specific row | `"ok"` (full video row), `"partial"` (real video row missing one optional field or fewer than 100 returned), `"empty"` (channel resolved but has zero uploads), `"error"` (channel could not be resolved, or the request was blocked) |
| `confidence` | Fixed marker on every video row | Always `"display-text"` — a reminder that `viewCountText`/`ageText` are the source's own rounded strings, never a computed exact value; `null` on summary rows, since there is no measurement to qualify |
| `partial` | Whether this row's own data is incomplete for a stated reason | `true` when `ageText` is absent, `title` is absent, or the channel returned fewer than the 100-video ceiling; never a silent gap — see `action` |
| `action` | Suggested downstream handling for this row | `"ingest"` (use as-is), `"flag-missing-title"`, `"flag-no-age"`, or `"skip-not-found"` (no video identity to act on) |
| `error` | Human-readable reason, or `null` | Populated only on `status:"error"` rows; `null` — never an empty string — on every other status, including `"empty"` |
| `checkedAt` | ISO-8601 timestamp of this row's own request | Shared granularity within one run, not a single run-wide timestamp — each channel is checked independently |

### Evidence and boundaries

**Why the uploads playlist, and not the `/videos` tab, is the primary source.** A live measurement on
`@mkbhd` on 2026-08-17 pulled both pages: the uploads playlist (`/playlist?list=UUBJycsmduvYEL83R_U4JriQ`)
returned exactly 100 `videoId`s at `HTTP 200`, 2,066,685 bytes; the `/videos` tab
(`/@mkbhd/videos?hl=en&gl=US`) returned only 30, at 1,167,699 bytes. Comparing the two sets directly, 9
of the playlist's first 30 videos were absent from the `/videos` tab's own 30 — YouTube's own `/videos`
tab quietly filters out a channel's Shorts, while the uploads playlist does not. An earlier design for
this Actor line cross-referenced age data exclusively through the `/videos` tab and would have silently
dropped those 9 positions' age data as a result; this build instead reads age directly off the
uploads-playlist page's own `lockupViewModel` cards first, and only falls back to the `/videos` tab when
the playlist page itself leaves a gap — confirmed live to cover 30 of 30 positions in the age window
without that fallback ever firing, on the channel this page's own examples are built from.

**Why the video ceiling is exactly 100, and why that is a source limit, not a policy choice.** The
uploads-playlist page itself returns exactly 100 `videoId`s in one `GET` request; going further requires
pagination through `/youtubei/v1/browse`, and `robots.txt` on `www.youtube.com` disallows `/youtubei/`
outright. There is no design where this Actor could honestly offer more than 100 recent uploads per run
through this request pattern — 100 is the source's own single-request ceiling, not a number chosen for
this product.

**Why publish age stops at position 30, even though the uploads-playlist page itself can carry it
further.** This is a deliberate contract choice, not a second source limit. The `/videos`-tab fallback
this Actor uses when the playlist page leaves a gap only ever renders a channel's most recent ~30
uploads — so widening the published age window past 30 would make rows beyond it depend entirely on
whether the playlist page itself happened to carry age data that far, an inconsistency this Actor
chooses not to ship. Positions 31–100 report `ageText:null` honestly, every time, rather than
sometimes-yes-sometimes-no depending on an internal fallback the buyer cannot see.

**Why view counts and publish age are rounded display text, never exact numbers.** YouTube's own pages
do not publish an exact view count or an exact publish timestamp on these surfaces — they publish
`"2.9M views"` and `"5 days ago"`, already rounded, already relative. This Actor reads exactly what the
source displays and reports it as `confidence:"display-text"` on every video row, rather than attempting
to reverse-engineer or estimate a precise figure the source itself does not expose here.

**Why a title can be missing without the row becoming an error.** An earlier live measurement across a
full 100-video pull on this same channel found 99 of 100 positions carrying a title, with one position
returning no title at all despite a clean row otherwise — the uploads-playlist page simply omits it for
that one entry. This Actor's own live run for this page (2026-08-17, quoted above) happened to return
all 100 titles — the condition is real but not deterministic, observed on one measurement and absent on
another against the same channel days apart. The code path exists specifically because it is real, even
on a run where it did not trigger: a missing title marks the row `partial:true` with
`action:"flag-missing-title"`, never `status:"error"`.

**Why `aboutChannelViewModel` is matched by identity, never by page position.** YouTube's `/about` page
carries the block this Actor uses to resolve a channel ID inside a JSON structure named
`aboutChannelViewModel`, which has been measured to occur on the page anywhere from one to two times —
the occurrence count is not a stable contract. An earlier design that always trusted "the second
occurrence" would silently return the wrong channel's identity — at a clean `HTTP 200` — the moment the
page's own shape drifted to carrying only one. This Actor instead checks every occurrence's own identity
fields (`channelId` for a bare-id request, the `@handle` segment of `canonicalChannelUrl` for a handle
request) against what was actually requested, and only accepts an occurrence that provably names that
channel — refusing outright, with an honest `error`, rather than guessing by position, when nothing on
the page confirms the requested identity.

**Why identity confirmation matters specifically for this Actor, more than for a channel-intel lookup.**
The resolved `channelId` here is not just a display field — it is the literal input used to construct
`uploadsPlaylistId`, which then determines every one of up to 100 video rows this run produces. A wrong
`channelId` from a position-based guess would not just mislabel one field; it would silently hand a
buyer 100 videos from an entirely different channel, under their own requested channel's name.

**The two-part negative case, both live-confirmed.** A syntactically valid but non-existent uploads
playlist id (this page's own `UC0000000000000000000000` and `UCUCxxxxxxxxxxxxxxxxxxxx` examples above)
returns `HTTP 200` with zero video entries — a genuine silent-empty, reported as `status:"empty"`, never
as an error. A handle that simply does not exist (`@this-handle-should-not-exist-zzz123`, also quoted
above) fails one step earlier, at channel resolution itself, with a genuine `HTTP 404`. This Actor's
code additionally recognizes a third shape neither example above happened to trigger: a syntactically
valid `channel/UC…` URL whose `/about` page returns `HTTP 200` with the literal text "This channel does
not exist" in the body — status code alone is not sufficient to detect this case, so the body is checked
directly. This third path is documented in this Actor's own source (`lib/channel.js`,
`bodyClaimsChannelDoesNotExist`) but was not independently re-triggered live while writing this page.

**The consent-cookie and locale requirement, and why its absence is dangerous rather than merely
inconvenient.** Every request this Actor makes to YouTube carries `?hl=en&gl=US` and a
`Cookie: CONSENT=YES+1` header. Without both, the page renders in whatever language the runner's own IP
address implies, and this Actor's English-token parsing (`"views"`, `"ago"`) returns a clean, confident,
**wrong** empty result at `HTTP 200` — a more dangerous failure mode than a network error, because
nothing about the response signals that anything went wrong.

**Why this Actor does not, and structurally cannot, deliver transcripts or comments.**
`robots.txt` on `www.youtube.com` disallows `/api/`, `/youtubei/`, `/results`, and `/comment` — the
paths transcript and comment retrieval require. This Actor's three request paths — `/@handle/about`,
`/@handle/videos`, and `/playlist` — are not on that disallowed list. This is not a feature this Actor
chose to leave out; it is a boundary the source's own published policy draws, and no version of this
Actor built on this request pattern can cross it.

**vs. `youtube-channel-intel`.** That Actor answers "who is this channel" — subscriber count, total
views, join date, business email, external links — from the same `/about` page's own
`aboutChannelViewModel` block this Actor also resolves a channel ID from. It never returns a video list.
This Actor answers "what has this channel posted recently" and never returns subscriber counts, contact
information, or external links. The channel-ID resolution step is structurally similar between the two
because both need it — but each is a separate purchase, and neither substitutes for the other.

**vs. `youtube-playlist-scraper`.** Both Actors ultimately request the same `/playlist?list=…` endpoint.
The difference is entirely in what goes into `list=`. This Actor accepts a **channel** and builds the
`UU…` uploads-playlist id itself — you never supply or see that identifier. `youtube-playlist-scraper`
accepts an **arbitrary `list=` value you already have** — someone else's public curated playlist, a
topic compilation, anything with a real YouTube playlist id — and performs no channel resolution at all.
If what you have is a channel, use this Actor. If what you have is a specific playlist URL that is not
necessarily anyone's own uploads, use that one instead.

**vs. `youtube-channel-lookalike-finder`.** That Actor reads the "similar channels" widget on the same
`/about` page — a completely different block (`gridChannelRenderer`) from the one this Actor reads. It
returns other channels' identities, never a video list for the requested channel itself.

### Decision routing

| Row shape | Billable? | `status` / `action` | What it means |
|---|---|---|---|
| Video inside the age window | **Yes** | `"ok"` / `"ingest"` | Full row: title, view count, publish age all present |
| Video outside the age window (position 31–100) | **Yes** | `"partial"` / `"flag-no-age"` | Real video, published contract boundary, not a fault |
| Video with a missing title | **Yes** | `"partial"` / `"flag-missing-title"` | Rare, source-side omission at `HTTP 200`; code path exists, not observed on this page's own live run |
| Video from a channel with fewer than 100 uploads | **Yes** | `"partial"` | Every returned row is billed; the channel simply had fewer than the ceiling to give |
| Channel confirmed to have zero uploads | No | `"empty"` / `"skip-not-found"` | Genuine `HTTP 200`, zero elements — never billed, `error` stays `null` |
| Channel handle/URL does not resolve | No | `"error"` / `"skip-not-found"` | Source's own `HTTP 404`, or body text "This channel does not exist," quoted verbatim in `error` |
| Non-YouTube host supplied as a channel value | No | `"error"` / `"skip-not-found"` | Blocked before any network request — see the SSRF-guard example above |
| Run-level spend cap reached mid-list | No | one free explanatory summary row | Names how many channels were skipped and why; already-billed rows are unaffected |

A downstream workflow only needs two rules: act on any row where `videoId` is not `null`, and never
treat `status:"partial"` as a fault — it names a specific, honest reason a field is absent, not a
degraded run.

### Commercial playbooks

#### Competitor upload-cadence tracking

A content or growth team runs this Actor on a short list of competitor or peer channels on a recurring
schedule, reading `title` and `checkedAt` across runs to answer "how often does this channel publish,
and on what" — without a human opening each channel's `/videos` tab by hand every week.

#### Content-strategy audits for agency clients

An agency onboarding a new client, or pitching a prospect, pulls a clean 100-video snapshot of the
channel's recent output as a starting artifact for a content review — title patterns, upload frequency
implied by `ageText` across the newest 30, and view-count spread across `viewCountText` — before writing
recommendations.

#### Media-monitoring digest across many channels

A PR or monitoring team feeds a list of up to 50 channels in one run, filters the dataset to
`status:"ok"` rows within the age window, and pushes the result into a shared review queue or a daily
digest — flat rows an existing pipeline can consume directly, with no HTML parsing of its own.

#### Feed input for a downstream "what's new" watch

Because this Actor is deliberately stateless between runs, a buyer who wants change detection — "alert
me only on genuinely new uploads" — builds that layer on top, diffing `videoId` sets between two runs'
datasets. This Actor's job stops at "here is this channel's current upload list, accurately and
honestly labelled"; deciding what changed since last time is a downstream concern this page does not
overstate as already solved.

### Integration recipes

![Where this Actor sits in your workflow](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e1ce54c6c199c6d374dde18443e244118994a66b/hub10/youtube-channel-videos-list/readme-workflow.webp)

#### Scheduled channel digest

An Apify Task runs this Actor daily or weekly against a fixed channel list, reading the Dataset for
`status:"ok"`/`"partial"` rows and appending them to a content-calendar spreadsheet or a Slack/Notion
digest, keyed by `videoId` so a video already logged is never duplicated across runs.

#### Agent / MCP pattern

An agent supplies only `channels` (and optionally `maxConcurrency`), reads `status` and `partial` on
every row before treating it as complete, and never assumes `ageText` is present past position 30 —
`action` on each row names the exact reason when it is not, so the agent can branch on a fixed,
documented value instead of inferring intent from a `null`.

#### New-upload diffing

Because `videoId` is stable across runs for the same video, a workflow that keeps its own store of
previously seen ids per channel can compute a simple set difference after each scheduled run to surface
only genuinely new uploads — a pattern this Actor's own stateless design deliberately leaves to the
buyer's own downstream logic rather than half-implementing a baseline this Actor does not itself
maintain or bill for.

#### Data-warehouse append pattern

Dataset rows are appended keyed by `videoId` when present, `requestedChannel` + `checkedAt` otherwise (a
channel-level summary row carries no `videoId`); `null` is preserved as `null` rather than coerced to an
empty string, so a downstream query can distinguish "the source did not publish this field for this
video" from "this field is genuinely blank."

### Operating guide

**Choosing between a handle/URL and a bare channel ID.** Supplying a bare `UC…` id skips the `/about`
resolution request entirely — one fewer ~2.5 MB fetch per channel, and one fewer place a request can
fail. If you already know a channel's id from a prior run's `channelId` field, reuse it directly on the
next run.

**Why `maxConcurrency` defaults to 5, not higher.** This Actor's live verification covered sequential
requests and moderate parallelism; it has not been load-tested against a sustained high-concurrency
burst directly against YouTube's own infrastructure. Raising it for a large channel list is honored, but
treat it as an unverified setting for very large batches — if you see a spike in `status:"error"` rows
with a network-level `error` message (timeout, connection reset) rather than a `404`/blocked-host
message, lowering concurrency is the first thing to try before assuming a channel itself is broken.

**Reading a `partial:true` row correctly.** This flag has three independent possible causes —
`ageText` missing (`action:"flag-no-age"`), `title` missing (`action:"flag-missing-title"`), or the
channel simply returned fewer than 100 videos total. `action` on the row names exactly which applies; do
not assume `partial:true` means "something went wrong" — in the overwhelming majority of cases on any
real channel, it means "this video is beyond the 30-position age window," which is expected, not rare.

**Why age enrichment via the `/videos` tab is conditional, not automatic.** This Actor only issues the
extra `/videos`-tab request when the uploads-playlist page itself leaves a genuine gap somewhere in
positions 1–30. On the channel this page's own examples are built from, that gap did not occur — all 30
positions were covered by the playlist page alone, and `source` reads `"uploads-playlist"` on every row.
When the fallback does fire on a different channel, expect one additional ~1.2 MB request for that
channel only, and `source:"uploads-playlist+videos-crossref"` on the specific rows it filled in.

**What changes between two runs of the same channel.** `position` is not a stable video identifier
across runs — if the channel uploads a new video between two runs, everything shifts down by one
position. Track a specific video by `videoId`, never by `position`, in any downstream storage.

#### Related tools

Related tools for adjacent workflows in social platforms and regional directories.

| Actor | What it does |
|---|---|
| [YouTube Channel Intel](https://apify.com/zinin/youtube-channel-intel) | Pair it in the social platforms and regional directories workflow: Public intel card for any YouTube channel: subscribers, total views, video count, join date, country, full... |
| [Telegram Export Intelligence](https://apify.com/zinin/telegram-channel-intel) | Pair it in the social platforms and regional directories workflow: Turn owner-authorized Telegram message exports into per-post reach context, evidence, confidence, review... |
| [Authorized Professional Directory Intelligence](https://apify.com/zinin/doctoralia-directory) | Pair it in the social platforms and regional directories workflow: Turn buyer-authorized or licensed professional directory exports into privacy-aware evidence, freshness,... |

### FAQ

#### Does this Actor return video transcripts or comments?

No, and it structurally cannot from this request pattern. `robots.txt` on `www.youtube.com` disallows
the `/api/`, `/youtubei/`, `/results`, and `/comment` paths those features require; this Actor only ever
requests `/about`, `/videos`, and `/playlist`, none of which are disallowed. See Evidence and boundaries
for the exact robots.txt lines.

#### Why is the ceiling exactly 100 videos, and not higher?

Because that is the source's own single-request limit on the uploads-playlist page. Retrieving more
requires paginating through `/youtubei/v1/browse`, a path `robots.txt` disallows. There is no
configuration of this Actor that returns more than 100 recent uploads per channel per run.

#### Why does publish age stop at position 30 when the playlist page can sometimes carry it further?

Because the fallback path this Actor uses when the playlist page leaves a gap — the `/videos` tab — only
ever renders a channel's most recent ~30 uploads. Extending the published window past 30 would make
some runs' coverage depend on an internal fallback the buyer cannot observe. This Actor ships a fixed,
predictable boundary instead: `ageText` for 1–30, honestly `null` beyond, every time.

#### Are the view counts exact numbers?

No. `viewCountText` is YouTube's own rounded display string — `"2.9M views"`, `"7M views"` — exactly as
the source publishes it. `confidence:"display-text"` on every video row is a permanent reminder of this,
not a value that ever changes to something more precise.

#### What happens for a channel that has never uploaded anything?

A syntactically valid but non-existent uploads-playlist id returns a genuine `HTTP 200` with zero
elements, live-confirmed twice while writing this page (see the silent-empty example above) — this
Actor reports `status:"empty"`, `error:null`, and charges nothing. This confirms the request pattern's
behavior on a non-existent identity; a real channel that genuinely uploaded zero videos was not
independently located and tested live for this page, though the request this Actor sends is identical
either way — see Limits.

#### Does this include YouTube Shorts?

Yes, because this Actor reads the uploads playlist, not the `/videos` tab — the tab is the surface that
filters Shorts out, live-confirmed missing 9 of a channel's first 30 uploads on that shorter list. There
is no separate field marking a row as a Short specifically; a Short appears as an ordinary video row
with its own `videoId`, `title`, and counts.

#### Can I tell which videos are new since my last run?

Not from a single run's fields directly — `position` shifts as a channel uploads, and this Actor keeps
no baseline between runs by design (see Pricing: a stateless per-video price, not a subscription-watch
price). Diff `videoId` sets between two runs' datasets in your own storage to build that layer; see
Integration recipes.

#### What happens with an invalid or non-YouTube URL in `channels`?

It is rejected before any network request, free of charge, with `error` naming the exact reason —
`"blocked host (not a youtube.com URL): …"` for a wrong domain, or a path-separator/scheme message for a
bare value that looks like it is trying to smuggle a path. See the guard-path example above for the
exact live row.

#### Is this the same data as YouTube's official Data API?

No — this Actor reads the same public pages a browser renders, not YouTube's own quota-metered Data
API. That means no API key and no quota to manage, and also means the shape of the output depends on
YouTube's own page structure remaining stable, the same dependency any page-reading Actor carries — see
Limits.

### Sources and rights

Source family: YouTube public channel and playlist pages. This Actor reads the channel's own uploads playlist page, anonymously, with no login, no API key and no browser session.

**The only source.** `www.youtube.com` — specifically `/{@handle|channel/UC…}/about` (channel
resolution only, skipped for a bare channel-ID input), `/playlist?list=UU…` (this channel's own uploads
playlist, the primary video-list and age source), and conditionally `/{@handle|channel/UC…}/videos`
(fallback age enrichment only). No YouTube Data API key, no OAuth, no login.

**`robots.txt`, checked directly.** `www.youtube.com/robots.txt` disallows `/api/`, `/youtubei/`,
`/results`, and `/comment`. It does not disallow `/@handle/about`, `/@handle/videos`, `/watch`, or
`/playlist` — the four path shapes this Actor's three request types are built from. This was read
directly from the published policy, not assumed from general knowledge of the platform.

**What this data is.** Everything this Actor returns — video titles, rounded view counts, rounded
publish age, a channel's own canonical URL — is public metadata YouTube itself displays to any visitor
of these pages, with no login required to view it. This Actor does not access, and this page makes no
claim about, any content beyond what these three specific public page types already show a signed-out
visitor.

**No redistribution license was found or is claimed here.** Unlike a government open-data source with
an explicit published reuse licence, this Actor's authors did not find, and this page does not claim,
any specific licence grant from YouTube covering redistribution of this page data. What is delivered is
exactly the public metadata these specific pages display — buyers are responsible for their own review
of how they use it downstream.

**No warranty of continued page stability.** This Actor's extraction depends on the current JSON
structures YouTube's own front end embeds in these pages (`aboutChannelViewModel`, `lockupViewModel`,
`contentMetadataViewModel`, and related renderer keys). YouTube can change this structure without
notice; this Actor's own parsing tries multiple known key shapes in a documented order specifically to
absorb minor rollout variation, but a substantial front-end redesign of these specific pages could
degrade or break extraction until this Actor is updated.

### Limits

- No transcripts, no comments, no engagement metrics beyond a rounded view count — `robots.txt` blocks
  the request paths those would require, on this host, for any Actor built this way.
- Hard ceiling of 100 most recent uploads per channel per run — the uploads-playlist page's own
  single-request limit; further pagination requires a `robots.txt`-disallowed path.
- Publish age (`ageText`) is populated only for positions 1–30, by a fixed contract boundary, not a
  moving source limit — see Evidence and boundaries for why this Actor does not widen it opportunistically.
- View counts and publish age are YouTube's own rounded display text, never exact numbers or exact
  timestamps.
- `title` is occasionally absent for a specific video even at a clean `HTTP 200` — measured once at 99
  of 100 positions on one run, and at 100 of 100 on this page's own live run days later; the condition is
  real but not deterministic, and never fails the row.
- This Actor keeps no baseline between runs — each run is an independent snapshot; "what's new since
  last time" is a downstream computation on `videoId`, not a built-in feature. See Integration recipes.
- `position` is not a stable identifier across runs; a channel uploading between two runs shifts every
  later position down by one. Track a video by `videoId`.
- No dedicated field marks a row as a YouTube Short; Shorts are included (unlike the `/videos` tab,
  which filters them) but are not distinguished from long-form uploads in the row shape.
- The genuine-zero-upload silent-empty behavior was live-confirmed on a syntactically valid but
  non-existent uploads-playlist id, twice, while writing this page — not independently re-confirmed
  against a real, human-registered channel known to have zero uploads, though the request pattern used
  is identical in both cases.
- The `HTTP 200` + "This channel does not exist" body-text negative case (a third resolution-failure
  shape, distinct from a plain `404`) is implemented in this Actor's code but was not independently
  re-triggered live while writing this page.
- Extraction depends on YouTube's current front-end JSON shapes remaining stable; a major redesign of
  these specific public pages could require an update to keep working.

### Support boundary

Actor support covers deterministic input validation, the source-access and extraction behavior
described on this page, the Dataset schema, and the billing behavior of the delivered-video-row pricing
model for an accepted release. It cannot guarantee YouTube's own page structure will remain unchanged,
cannot restore extraction if YouTube redesigns these specific public pages before this Actor is updated
to match, cannot provide content-strategy or competitive advice beyond the raw data delivered, and
cannot retrieve transcripts, comments, or any data behind a `robots.txt`-disallowed path regardless of
how the request is asked. When reporting a problem, include the Actor run ID, the exact `channels` value
that produced it, the `status`/`action`/`error` on the affected row, and the `checkedAt` time.

# Actor input Schema

## `channels` (type: `array`):

List of channels: @handle, full URL (https://www.youtube.com/@handle), or bare channel ID (UC...). The Actor resolves the channel and builds its uploads playlist for you — no need to know the technical UU identifier.

## `maxConcurrency` (type: `integer`):

How many channels to process in parallel. Kept conservative by default — stability has only been verified live on sequential requests, not under sustained high concurrency.

## Actor input object example

```json
{
  "channels": [
    "@mkbhd"
  ],
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (type: `string`):

API URL for the default dataset items produced by this run.

## `runSummary` (type: `string`):

One JSON object written once at the end of this run: requestedCount, deliveredRows, paidRows, freeRows, failedCount, incompleteRows, replaySafe + replaySafeReason, fatalError + fatalErrorReason, generatedAt.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "channels": [
        "@mkbhd"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/youtube-channel-videos-list").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "channels": ["@mkbhd"] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/youtube-channel-videos-list").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "channels": [
    "@mkbhd"
  ]
}' |
apify call zinin/youtube-channel-videos-list --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zinin/youtube-channel-videos-list"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/H1nPuGJRizbtv5xGm/builds/1QClGhhb3izBRiV2F/openapi.json
