YouTube Without an API Key: RSS, oEmbed, timedtext, and the Endpoints Google Doesn't Document
The channel feed carries view and like counts nobody mentions, handle-to-channel-ID has four fixes, and captions return zero bytes. Measured 2026-08-20.
10 min read · 20 Aug 2026
You wanted the last fifteen videos from a channel. You now have a Google Cloud project, an OAuth consent screen you did not want to fill in, a quota dashboard, and a quotaExceeded error at four in the afternoon.
There is a surface underneath all of that which needs no key, no project, no quota and no proxy, and it is bigger than most write-ups admit. This is what it actually returns, measured on 2026-08-20 with curl, byte counts included.
Two things this post will tell you that the usual answer gets wrong: the channel RSS feed does carry view counts and like counts, and the handle-to-channel-ID problem has four solutions on a single page fetch, one of which hands you the feed URL directly.
YouTube is the most open platform in this category by a wide margin — the same probes run against nine others are collected in what each social platform returns without an API key.
First, the ceiling you are trying to get out from under
Worth quoting exactly, because the shape of it decides whether the free surface is worth your time.
From Google's own quota table:
Projects that enable the YouTube Data API have a default quota allocation of 100
search.listcalls, 100videos.insertcalls, and 10,000 units per day combined for all other endpoints.
Search sits in its own bucket with a hard daily count of 100 calls, priced at 1 unit each. It is a wall, not a budget line you can trade against by economising elsewhere.
For everything else:
| Method | Units |
|---|---|
channels.list |
1 |
videos.list |
1 |
playlistItems.list |
1 |
commentThreads.list |
1 |
captions.list |
50 |
captions.insert |
400 |
captions.update |
450 |
Two clauses in that documentation cost people more than the table does. "All API requests, including invalid requests, incur a quota cost of at least one point" — your bugs are billed. And each additional page of a paginated result incurs the cost again — so "one call" that walks 40 pages is 40 units, not one.
10,000 units is a lot of videos.list and almost no search. If your ceiling is search, no amount of key management fixes it. If your ceiling is polling a set of known channels, you may not need the API at all.
Channel RSS: 15 videos, no key, no quota
GET https://www.youtube.com/feeds/videos.xml?channel_id=UCBJycsmduvYEL83R_U4JriQ
200, text/xml, 21,438 bytes — 3,886 on the wire with gzip. Exactly 15 entries. We ran it against five channels of very different sizes and shapes (MKBHD, MrBeast, Fireship, Google Developers, CNN) and got 15 entries every time.
Here is one entry, complete, with nothing removed:
<entry>
<id>yt:video:o4SSoURPODY</id>
<yt:videoId>o4SSoURPODY</yt:videoId>
<yt:channelId>UCBJycsmduvYEL83R_U4JriQ</yt:channelId>
<title>Google Pixel 11/Pro/Fold Impressions: It Is What It Is</title>
<link rel="alternate" href="https://www.youtube.com/watch?v=o4SSoURPODY"/>
<author>
<name>Marques Brownlee</name>
<uri>https://www.youtube.com/channel/UCBJycsmduvYEL83R_U4JriQ</uri>
</author>
<published>2026-08-12T14:00:35+00:00</published>
<updated>2026-08-16T03:13:02+00:00</updated>
<media:group>
<media:title>Google Pixel 11/Pro/Fold Impressions: It Is What It Is</media:title>
<media:thumbnail url="https://i4.ytimg.com/vi/o4SSoURPODY/hqdefault.jpg" width="480" height="360"/>
<media:description>Every year, a new Pixel, and new hopes and dreams...</media:description>
<media:community>
<media:starRating count="84342" average="5.00" min="1" max="5"/>
<media:statistics views="3132180"/>
</media:community>
</media:group>
</entry>
Look at media:community. starRating count is the like count and statistics views is the view count, and both were present on all 75 entries across all five channels we checked. If you came here assuming a feed means titles and links and that engagement numbers require the API, that assumption is the one to drop first.
You also get the full video description in media:description — not a truncated snippet — plus a real ISO timestamp for publication and a separate updated timestamp for the last metadata change. videos.list still beats it on completeness (duration, tags, category, live status, exact comment count), but for title, description, thumbnail, publish time, views and likes across a channel's fifteen most recent uploads, the feed is a straight substitute at zero units.
Freshness is good. CNN's newest entry when we pulled it was timestamped 2026-08-20T06:58:36+00:00, minutes old.
Two other ways to address the same feed
?user=<legacy username> still resolves for channels old enough to have one: ?user=marquesbrownlee returned 200 and 21,423 bytes. ?user=@mkbhd returns 404 — the parameter takes the pre-2013 username, not the handle, and this trips people constantly.
?playlist_id=<id> works and is the more useful variant. Any channel's uploads playlist is its channel ID with UC swapped for UU, so UCBJycsmduvYEL83R_U4JriQ → UUBJycsmduvYEL83R_U4JriQ, and that returned 200 with 15 entries. Same for any regular playlist. A garbage channel ID returns 404, so bad input is at least loud.
The limits, stated exactly
Fifteen entries. That is the whole history you can have. There is no cursor, no ?max-results, no page two. If a channel posts twenty videos while you are not looking, you have lost five and there is no way to notice from the feed alone.
Cache-Control: public, max-age=900, and no ETag, no Last-Modified. We tried conditional requests both ways and both came back 200 with the full body. There is no 304 path here — you cannot poll cheaply, only less often. Fifteen minutes is the number Google is telling you; take it.
No Access-Control-Allow-Origin header. Not callable from a browser. Proxy it or fetch it server-side.
The view count is not monotonic. Six consecutive fetches of the same feed returned 3132180, 3132278, 3132180, 3132180, 3132180, 3132180; a pull ten minutes earlier had said 3131859. The number goes up, down, and back up depending on which cache shard answers. If you diff feeds to detect change, a raw fingerprint over the XML will report a change on almost every poll and none of them will be real — a specific and expensive failure mode if anything downstream is priced per change. Compare video IDs, or smooth the counters, or ignore them for change detection.
Twenty back-to-back requests across five channels: 20/20 succeeded, no throttling. That tells you nothing about 20,000, and there is no documented limit to point at.
The catch: you need the channel ID, and @handle isn't it
Every route above wants a UC… ID. Your users will give you @mkbhd. YouTube provides no lightweight resolver, which is the actual reason people give up and get an API key.
There are four ways out, and all four come from the same single fetch of https://www.youtube.com/@mkbhd:
<link rel="canonical" href="https://www.youtube.com/channel/UCBJycsmduvYEL83R_U4JriQ">
<meta property="og:url" content="https://www.youtube.com/channel/UCBJycsmduvYEL83R_U4JriQ">
<meta itemprop="identifier" content="UCBJycsmduvYEL83R_U4JriQ">
<link rel="alternate" type="application/rss+xml" title="RSS"
href="https://www.youtube.com/feeds/videos.xml?channel_id=UCBJycsmduvYEL83R_U4JriQ">
Take the fourth one. The page tells you its own feed URL. You do not have to know the feeds/videos.xml?channel_id= convention, you do not have to reassemble anything, and if Google ever changes the feed path your code follows it for free. Regex for type="application/rss+xml", take the href, done.
The other three are fine fallbacks; grab whichever appears first and cross-check.
Now the cost, because it is not small:
| Fetch | Uncompressed | Gzipped |
|---|---|---|
youtube.com/@mkbhd |
2,754,441 B | 377,844 B |
youtube.com/@mkbhd/videos |
1,166,130 B | 259,274 B |
feeds/videos.xml?channel_id=… |
21,438 B | 3,886 B |
youtube.com/oembed?url=… |
868 B | — |
Two practical consequences. Request the /videos tab, not the channel root — it carries the same canonical ID and the same rel="alternate" feed link at a third of the raw size. And always send accept-encoding: gzip; the difference is 7× on the channel page and 5× on the feed, and a surprising number of scripts do not.
Do not try to shortcut it with a range request. Range: bytes=0-4095 is ignored — you get 200 and the full 2.5 MB, not a 206. The canonical link sits around byte 733,000 anyway, well behind the embedded ytInitialData blob, so there is no prefix to grab.
Resolve once, cache forever. A channel ID never changes; a handle can be renamed. Store the UC… as your key and treat the handle as a display label.
If you already have a key, channels.list?forHandle=@mkbhd costs 1 unit and is obviously easier. This section is for people who do not want the key at all.
oEmbed: title, author and thumbnail for 868 bytes
GET https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&format=json
200, 868 bytes:
{
"title": "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)",
"author_name": "Rick Astley",
"author_url": "https://www.youtube.com/@RickAstleyYT",
"type": "video",
"provider_name": "YouTube",
"thumbnail_url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
"thumbnail_width": 480, "thumbnail_height": 360,
"html": "<iframe …>"
}
Measured behaviour worth knowing:
- It reflects your
OrigininAccess-Control-Allow-Origin. This is the one YouTube surface here you can call directly from a browser, which makes it the right primitive for a link-preview widget. - A video ID that resolves to nothing returns
400 Bad Request— a genuine, honest error, which is rarer on this platform than it should be. - It does not work on channel URLs. Both
/@mkbhdand/channel/UC…return404 Not Found. oEmbed is videos and playlists only. format=xmlworks if you want it.- Fifteen requests back to back: 15/15.
author_urlgives you the channel's @handle, not its ID — useful in reverse, useless for building a feed URL.
timedtext: the part that returns nothing
Captions look like they should be the easiest thing on this list. They are the hardest.
Fetch a watch page, pull ytInitialPlayerResponse, and every caption track has a baseUrl pointing at youtube.com/api/timedtext. On our test video all six language tracks carried &exp=xpe in that URL, and here is what each of them returned:
| Request | Status | Bytes |
|---|---|---|
baseUrl (no format) |
200 | 0 |
baseUrl&fmt=json3 |
200 | 0 |
baseUrl&fmt=srv1 |
200 | 0 |
baseUrl&fmt=srv3 |
200 | 0 |
baseUrl&fmt=vtt |
200 | 0 |
baseUrl&fmt=json3&c=WEB |
200 | 0 |
baseUrl&fmt=json3&pot=fake&c=WEB |
200 | 0 |
Seven variants, seven successful responses, zero bytes each time.
exp=xpe means the track is gated behind a proof-of-origin token, and without a valid one the endpoint answers 200 with nothing rather than telling you no. That is a deliberate choice, and it is why the leading Python library for this carries an open issue saying there is no workaround, and why yt-dlp shells out to a Node process. Notice from the table that a fake token does not help — the value is checked.
There is a working recipe. It involves running Google's BotGuard VM, which is JavaScript, so a Node service can do it in-process. We wrote up the whole thing, including the three details that cost us days — the mandatory &c=WEB, binding the token to the video ID rather than visitorData, and never shutting the VM down.
Where a video exposes a track without exp=xpe, that track works with a plain fetch. Check for it before you reach for any of the machinery.
Two things that are not endpoints but will bite you
The EU consent interstitial. Requests from EU egress can be served a consent page instead of the payload — 200, valid HTML, none of your data. The fix is a cookie header, CONSENT=YES+cb; SOCS=CAI, sent on every watch and channel fetch. Our own client sets it unconditionally. We could not reproduce the interstitial from a non-EU address, so treat that specific behaviour as reported rather than re-tested here; sending the cookie costs nothing either way.
Nothing here is a documented API. RSS and oEmbed are stable, standard, and have survived a decade. ytInitialData is a variable in a web page and changes shape whenever the frontend ships. Parse defensively, assert on two or three fields that must always be present, and return partial data rather than throwing when an optional one moves.
Choosing
| You need | Use | Cost |
|---|---|---|
| Latest 15 uploads from known channels | Channel RSS | 0, 15-min cache |
| Views and likes for those 15 | Channel RSS (media:community) |
0 |
| Title, author, thumbnail for one video URL | oEmbed | 0, browser-callable |
| A channel ID from a handle | The channel page's rel="alternate" feed link |
one 259 KB fetch, cache forever |
| More than 15 videos, or full history | Data API playlistItems.list on the UU… playlist |
1 unit per page |
| Search | Data API search.list |
100 calls/day, full stop |
| Comments | Data API commentThreads.list |
1 unit per page |
| View counts on an arbitrary video | Watch page ytInitialPlayerResponse, or videos.list |
1.3 MB, or 1 unit |
| Captions | timedtext plus a PO token | 0, and a day of your life |
The honest summary: if you are polling a known set of channels, the free surface is not a downgrade — it is better than the API on data-per-request and it has no ceiling. If you need search, history beyond 15 videos, or comments, get the key; nothing free replaces those.