List All Videos from a YouTube Channel via API
David Boulen · 7/15/2026 · 9 min read
List All Videos from a YouTube Channel via API
If you need to ingest every video a channel has ever published - to build a summarizer, feed a RAG pipeline, monitor for new content, or bulk-pull transcripts - you need a reliable way to enumerate channel uploads programmatically. YouTube's own Data API v3 can do this, but it comes with a 10,000-unit daily quota, a complicated OAuth setup, and a search.list call that burns 100 units per page. There is a more direct path.
This guide walks through the full workflow: resolving a channel handle to an ID, paginating through all uploads, filtering by publish date, combining video lists with transcript calls, and handling the quirks that come with large channels.

Step 1: Resolve the Channel Handle to an ID
Before you can list videos, you need a canonical channel identifier. Users share channels as @handles (@mkbhd), full URLs (https://www.youtube.com/@mkbhd), or raw UCIDs (UCBcRF18a7Qf58cMAttjdSz). Your pipeline should accept any of these without extra logic.
YouTube Transcriber's resolve endpoint handles all three formats:
curl "https://getyoutubetranscriber.com/api/v2/channel/resolve?input=%40mkbhd" \
-H "Authorization: Bearer YOUR_API_KEY"
Response:
{
"channel_id": "UCBcRF18a7Qf58cMAttjdSz",
"resolved_from": "@mkbhd"
}
This endpoint is free and returns the UCID you'll use for all subsequent calls. Store the channel_id and move on - no scraping, no redirect chains.
Why resolve first? If you skip this step and pass a raw @handle directly to video listing endpoints, some tools fail silently or return empty results. Resolving once at the start makes the rest of your pipeline deterministic.
Step 2: Paginate Through the Uploads Playlist
With the channel ID in hand, you can list every video the channel has published. The GET /api/v2/channel/videos endpoint accepts a channel parameter (any format, including the UCID you just resolved) and returns a page of results plus a continuation_token for the next page.
# First page
curl "https://getyoutubetranscriber.com/api/v2/channel/videos?channel=UCBcRF18a7Qf58cMAttjdSz" \
-H "Authorization: Bearer YOUR_API_KEY"
Example response (truncated):
{
"results": [
{
"type": "video",
"video_id": "dQw4w9WgXcQ",
"title": "My Latest Video",
"channel_id": "UCBcRF18a7Qf58cMAttjdSz",
"channel_title": "MKBHD",
"channel_handle": "@mkbhd",
"channel_verified": true,
"length_text": "12:34",
"view_count_text": "1.2M views",
"published_time_text": "3 days ago",
"has_captions": true,
"thumbnails": [
{ "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", "width": 480, "height": 360 }
]
}
],
"result_count": 30,
"continuation_token": "4qmFsgJ9EhhVQ...",
"has_more": true,
"playlist_info": {
"title": "Uploads from MKBHD",
"num_videos": 1847,
"owner_name": "MKBHD"
}
}
To walk all pages, loop until has_more is false:
import httpx
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://getyoutubetranscriber.com/api/v2"
def list_all_videos(channel_id: str) -> list[dict]:
headers = {"Authorization": f"Bearer {API_KEY}"}
videos = []
params = {"channel": channel_id}
while True:
resp = httpx.get(f"{BASE_URL}/channel/videos", headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
videos.extend(data["results"])
print(f"Fetched {len(videos)} videos so far...")
if not data.get("has_more"):
break
params["continuation"] = data["continuation_token"]
return videos
# Resolve handle first, then list
resolve_resp = httpx.get(
f"{BASE_URL}/channel/resolve",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"input": "@mkbhd"}
)
channel_id = resolve_resp.json()["channel_id"]
all_videos = list_all_videos(channel_id)
print(f"Total videos: {len(all_videos)}")
The same pattern in Node.js:
const axios = require('axios');
const API_KEY = 'YOUR_API_KEY';
const BASE_URL = 'https://getyoutubetranscriber.com/api/v2';
const headers = { Authorization: `Bearer ${API_KEY}` };
async function listAllVideos(channelId) {
const videos = [];
let params = { channel: channelId };
while (true) {
const { data } = await axios.get(`${BASE_URL}/channel/videos`, { headers, params });
videos.push(...data.results);
console.log(`Fetched ${videos.length} videos...`);
if (!data.has_more) break;
params = { channel: channelId, continuation: data.continuation_token };
}
return videos;
}
Step 3: Filter by Date and Fetch Metadata
Results come back in reverse chronological order (newest first). Each video object includes a published_time_text field like "3 days ago" or "2 years ago". For an approximate date filter - say, only videos from the last 90 days - you can parse this field or stop paginating once you hit videos past your cutoff.
For a pipeline that only needs recent content:
from datetime import datetime, timedelta
CUTOFF_DAYS = 90
def is_recent(published_time_text: str) -> bool:
text = published_time_text.lower()
if "hour" in text or "minute" in text or "second" in text:
return True
if "day" in text:
days = int(text.split()[0])
return days <= CUTOFF_DAYS
if "week" in text:
weeks = int(text.split()[0])
return weeks * 7 <= CUTOFF_DAYS
return False # months, years - outside window
def list_recent_videos(channel_id: str) -> list[dict]:
headers = {"Authorization": f"Bearer {API_KEY}"}
videos = []
params = {"channel": channel_id}
while True:
resp = httpx.get(f"{BASE_URL}/channel/videos", headers=headers, params=params)
data = resp.json()
for video in data["results"]:
if is_recent(video["published_time_text"]):
videos.append(video)
else:
# Since results are newest-first, once we hit old content we can stop
return videos
if not data.get("has_more"):
break
params["continuation"] = data["continuation_token"]
return videos
Each video object also gives you has_captions (a boolean), which is useful if your next step is transcript extraction - you can skip videos that have no captions rather than burning credits on a failed transcript call.
Step 4: Combine with Transcript Calls for Full Pipelines
Listing videos is usually the first stage of a larger pipeline. The common pattern: collect video IDs from channel listing, then call the transcript endpoint for each.
import asyncio
import httpx
async def fetch_transcript(client: httpx.AsyncClient, video_id: str) -> dict:
resp = await client.get(
"https://getyoutubetranscriber.com/api/v2/transcript",
params={"video_url": video_id}
)
resp.raise_for_status()
return {"video_id": video_id, "transcript": resp.json()}
async def build_channel_corpus(channel_id: str, max_concurrent: int = 5) -> list[dict]:
headers = {"Authorization": f"Bearer {API_KEY}"}
videos = list_all_videos(channel_id) # from earlier
# Only process videos that have captions
captioned = [v for v in videos if v.get("has_captions")]
print(f"{len(captioned)} of {len(videos)} videos have captions")
semaphore = asyncio.Semaphore(max_concurrent)
async def bounded_fetch(client, video_id):
async with semaphore:
return await fetch_transcript(client, video_id)
async with httpx.AsyncClient(headers=headers, timeout=30) as client:
tasks = [bounded_fetch(client, v["video_id"]) for v in captioned]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [r for r in results if not isinstance(r, Exception)]
corpus = asyncio.run(build_channel_corpus("UCBcRF18a7Qf58cMAttjdSz"))
This pattern - enumerate, filter by has_captions, then batch-fetch transcripts with a semaphore - is the foundation for most YouTube content pipelines. For a deeper look at feeding these transcripts into a retrieval system, see the guide on building a RAG pipeline on YouTube transcripts.

Step 5: Handling Large Channels Efficiently
Channels with thousands of videos (news networks, prolific creators, educational publishers) require a few extra considerations.
Checkpoint and resume. If your pipeline crashes halfway through a 2,000-video channel, you want to resume rather than restart. Save the last continuation_token to disk after each page:
import json
import pathlib
CHECKPOINT_FILE = pathlib.Path("channel_checkpoint.json")
def list_with_checkpoint(channel_id: str) -> list[dict]:
headers = {"Authorization": f"Bearer {API_KEY}"}
videos = []
params = {"channel": channel_id}
# Resume from checkpoint if it exists
if CHECKPOINT_FILE.exists():
checkpoint = json.loads(CHECKPOINT_FILE.read_text())
if checkpoint.get("channel_id") == channel_id:
params["continuation"] = checkpoint["continuation_token"]
videos = checkpoint.get("videos_so_far", [])
print(f"Resuming from checkpoint: {len(videos)} videos already fetched")
while True:
resp = httpx.get(f"{BASE_URL}/channel/videos", headers=headers, params=params)
data = resp.json()
videos.extend(data["results"])
if not data.get("has_more"):
CHECKPOINT_FILE.unlink(missing_ok=True)
break
token = data["continuation_token"]
CHECKPOINT_FILE.write_text(json.dumps({
"channel_id": channel_id,
"continuation_token": token,
"videos_so_far": videos
}))
params["continuation"] = token
return videos
Respect rate limits. YouTube Transcriber handles YouTube-side blocks and retries for you, but your own request rate to the API still matters. The channel videos endpoint is cheaper than transcript calls, so you can page quickly - but once you transition to bulk transcript fetching, throttle appropriately. For a thorough breakdown of rate limit behavior and backoff strategies, the post on understanding YouTube transcript rate limits covers the specifics.
Deduplicate across runs. If you run the pipeline regularly (say, daily to catch new uploads), maintain a local set of seen video IDs. Stop paginating once every video on a page is already in your set - for active channels, this usually means you only fetch one or two new pages per run rather than traversing the full history.
def list_new_videos(channel_id: str, seen_ids: set[str]) -> list[dict]:
headers = {"Authorization": f"Bearer {API_KEY}"}
new_videos = []
params = {"channel": channel_id}
while True:
resp = httpx.get(f"{BASE_URL}/channel/videos", headers=headers, params=params)
data = resp.json()
page_ids = {v["video_id"] for v in data["results"]}
already_seen = page_ids & seen_ids
for video in data["results"]:
if video["video_id"] not in seen_ids:
new_videos.append(video)
# If all videos on this page are known, no need to go further back
if already_seen == page_ids or not data.get("has_more"):
break
params["continuation"] = data["continuation_token"]
return new_videos
Go equivalent. For teams building ingest services in Go, the same pagination pattern works cleanly with net/http:
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
type Video struct {
VideoID string `json:"video_id"`
Title string `json:"title"`
PublishedTimeText string `json:"published_time_text"`
HasCaptions bool `json:"has_captions"`
}
type ChannelVideosResponse struct {
Results []Video `json:"results"`
ContinuationToken string `json:"continuation_token"`
HasMore bool `json:"has_more"`
}
func listAllVideos(channelID, apiKey string) ([]Video, error) {
client := &http.Client{}
var videos []Video
continuation := ""
for {
params := url.Values{"channel": {channelID}}
if continuation != "" {
params.Set("continuation", continuation)
}
req, _ := http.NewRequest("GET",
"https://getyoutubetranscriber.com/api/v2/channel/videos?"+params.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var result ChannelVideosResponse
json.Unmarshal(body, &result)
videos = append(videos, result.Results...)
fmt.Printf("Fetched %d videos...\n", len(videos))
if !result.HasMore {
break
}
continuation = result.ContinuationToken
}
return videos, nil
}
Comparing Approaches
If you have considered using the native YouTube Data API v3 or building your own scraper, here is a practical comparison:
| Approach | Setup | Quota / Cost | Handles IP Blocks | Pagination | | - -| - -| - -| - -| - -| | YouTube Data API v3 | Google Cloud project, OAuth | 10,000 units/day; search.list = 100 units/page | No | nextPageToken | | DIY scraper | Selenium or requests | Free but fragile | No, you manage proxies | Manual | | YouTube Transcriber | Bearer token only | Pay-per-successful-call; 100 free credits | Yes | continuation_token |
The YouTube Data API's quota math is the main friction point for channel enumeration at scale. With search.list at 100 units per call and a 10,000-unit daily cap, you can fetch at most 100 pages per day before hitting the limit - roughly 3,000 videos if each page returns 30 results. For channels with more history, you either need to request a quota increase or spread the crawl over multiple days.
For a full breakdown of the two approaches, see the YouTube Data API v3 vs a dedicated transcript API comparison.
If you are building at the scale of a research project or content ingestion pipeline, the practical difference between approaches is significant - the quota math alone can determine whether a one-day crawl is feasible or requires spreading work across a week.
Putting It All Together
A complete channel-to-transcript pipeline has four stages:
- Resolve the channel identifier to a UCID (one free call).
- Enumerate all videos using paginated
channel/videoscalls, stopping early if you only want recent content. - Filter to videos where
has_captionsis true to avoid wasted transcript calls. - Fetch transcripts concurrently, bounded by a semaphore to stay within rate limits.
The result is a structured dataset: video metadata (ID, title, publish date, view count) paired with clean transcript JSON - ready for indexing, summarization, or fine-tuning.
If you need to track a channel on an ongoing basis rather than doing a one-time crawl, that is a slightly different problem. It involves watching for new uploads on a schedule rather than walking history, and the deduplication approach from Step 5 above is the starting point for that pattern.
Get Started
The channel resolve and video listing endpoints are available to all YouTube Transcriber users. You start with 100 free credits, no credit card required. The API documentation at getyoutubetranscriber.com/docs has the full parameter reference and additional code examples for every endpoint covered here.