YouTube timedtext Returns 200 With an Empty Body: Why It Happens
YouTube's timedtext endpoint answers HTTP 200 with a zero-byte body when the request does not carry a proof-of-origin token minted inside the player. The token is absent from the page HTML and from ytInitialPlayerResponse, and every route that works either replays the player's own request or presents a browser TLS fingerprint.
TL;DR
A caption request without a player-minted proof-origin token gets HTTP 200 and zero bytes. Measured on 18 September 2026, the player's own URL returned 46,010 bytes on a 19-minute video and 85,495 bytes on another, and the same URL returned a zero-length body under a HeadlessChrome user agent.
Try it on your own script
Paste your draft below. You get your hook, structure, and pacing scores, a script-level attention-risk map, and the single biggest issue quoted from your own lines. Free, no login.
Free · No login · See a sample audit first if you prefer.
Key Takeaways
- Requests to the timedtext endpoint can return HTTP 200 with a zero-byte body because the request is missing a proof-of-origin token that YouTube mints inside the player
- That token does not appear in the page HTML, and it cannot be derived from ytInitialPlayerResponse
- Refetching the player's own caption URL with fmt=json3 returned 46,010 bytes on a 19-minute video and 85,495 bytes on another, measured 18 September 2026
- The same token-bearing URL returned a zero-length body under a HeadlessChrome user agent and the full JSON3 body under a normal Chrome user agent
- A worker container running yt-dlp 2026.08.19 without curl_cffi returned 0 caption characters while metadata succeeded; the same image with curl_cffi 0.16.3 and a proxy returned 18,143
- The official captions.download method requires permission to edit the video and costs 200 quota units per call, so it is not an option for another creator's public video
Key Statistics
- •Fetching captionTracks[].baseUrl from ytInitialPlayerResponse directly returned HTTP 200 with a body length of 0, measured in real Chrome on 18 September 2026.
- •Refetching the player's own caption URL with &fmt=json3 returned 46,010 bytes on a 19-minute video and 85,495 bytes on another, measured on 18 September 2026.
- •The same token-bearing URL returned a zero-length body under a HeadlessChrome user agent and the full JSON3 body under a normal Chrome user agent, measured on 18 September 2026.
- •A worker container running yt-dlp 2026.08.19 without curl_cffi returned 0 caption characters while metadata succeeded; the same image with curl_cffi 0.16.3 and a proxy returned 18,143, measured on 19 September 2026.
In This Guide
- YouTube timedtext Returns 200 With an Empty Body: Why It Happens
- Why youtube timedtext returns 200 with an empty body
- The measurements, 18 September 2026
- What the player's working URL contains
- Three approaches that work, and what each costs
- Why the user agent matters: the HeadlessChrome measurement
- Limits: undocumented behaviour that needs a canary
- Public videos only
YouTube timedtext Returns 200 With an Empty Body: Why It Happens
Requests to https://www.youtube.com/api/timedtext can return HTTP 200 with a zero-byte body. The endpoint is answering, and the request is missing something: a proof-of-origin token that YouTube mints inside the player. That token does not appear in the page HTML, and it cannot be derived from ytInitialPlayerResponse. Measured in real Chrome on 18 September 2026.
Why youtube timedtext returns 200 with an empty body
The endpoint answers 200 because the request is well formed, the video is public, and the caption track exists. The empty body is a separate decision, made when YouTube checks the request for a proof-of-origin token. That token is minted inside the player while the video plays, and it rides on the player's own caption request. A script that reads captionTracks[].baseUrl from the player response and fetches it sends a request with no token and receives an empty 200 rather than an error.
The token is not in the page HTML, not in ytInitialPlayerResponse, and not derivable from either, because the player computes it from state that does not reach the document. The MAIN-world bridge in prepublish-extension/src/content/youtube-main.js exists for that reason.
captionTracks[].baseUrl is a real URL for a real caption track, and fetching it returns nothing while reporting success at the HTTP layer. A scraper with no guard writes an empty string to the database and moves on.
The measurements, 18 September 2026
Every row below was measured in real Chrome and Chrome for Testing on 18 September 2026.
| Request | What came back |
|---|---|
captionTracks[].baseUrl from ytInitialPlayerResponse, fetched directly | HTTP 200, body length 0 |
| The same URL fetched from the page's own origin with cookies | HTTP 200, body length 0 |
| The URL the player itself requests | carries &potc=1&pot=<token>&key=yt8&lang=en&fmt=json3 |
Refetching the player's URL with &fmt=json3 | HTTP 200, 46,010 bytes on a 19-minute video; 85,495 bytes on another |
The same refetch under a HeadlessChrome user agent | HTTP 200, body length 0 |
| The same refetch after setting a normal Chrome user agent | full JSON3 body returned |
InnerTube /youtubei/v1/get_transcript with the panel's own params | HTTP 400, FAILED_PRECONDITION, Precondition check failed |
| The DOM transcript panel in a signed-out headless browser | panel opens, ytd-continuation-item-renderer never resolves, zero segments |
The first two rows are the confusing ones. The same URL, fetched with the page's own cookies, from the page's own origin, in a browser that already loaded the video, returned nothing. Cookies do not carry the token, and origin does not carry it either.
Rows seven and eight close off the routes that forum answers recommend. The InnerTube get_transcript call, sent with the parameters the transcript panel uses, answered HTTP 400 with FAILED_PRECONDITION and the message Precondition check failed. The rendered panel opens in a signed-out headless browser and never resolves its continuation, so it produces zero segments.
What the player's working URL contains
The URL the player requested, the one that returned 46,010 bytes, carries these parameters.
| Parameter | What it is | Whether it matters |
|---|---|---|
pot | the proof-of-origin token minted inside the player | the gate. Remove it and the same URL returns zero bytes |
potc=1 | accompanies the token on the player's caption request | observed alongside the token on every player request in the capture; we have not measured one without the other |
key=yt8 | the API key the player attaches | present on player requests and on the baseUrl; not what is missing from a direct fetch |
lang | the track language | selects the track, and keeps naming the original track even when a translation is applied |
kind | marks an auto-generated track as asr | distinguishes creator-uploaded from automatic captions |
fmt | the body format | fmt=json3 returns { events: [ { tStartMs, dDurationMs, segs: [ { utf8 } ] } ] }, the shape the byte counts above were measured on |
tlang | the auto-translation code | delete it. It derives a translated stream from a real track while lang still names the original language |
v | the video id | routing, not gate |
Read that list as three groups. pot is the part that was missing. fmt is the part you choose, because the body you want is JSON3 rather than the default timed-text document. tlang is the part you delete: a URL carrying it returns a body in the viewer's language while lang still names the original track, so a pull that stores the body labels a translation as the original.
We did not attempt to construct a pot. The measurements describe what the player sent, and the approaches below reuse the player's own request.
Three approaches that work, and what each costs
Approach 1: observe the player's own request from a MAIN-world content script
A content script running in the page's MAIN world at document_start replaces window.fetch and XMLHttpRequest.prototype.open with wrappers that record any URL whose pathname is exactly /api/timedtext on a YouTube host. The wrapper therefore keeps a URL that already carries the token, and the caller refetches it with &fmt=json3 forced, which is how the 46,010-byte body was obtained. The implementation ships in prepublish-extension/src/content/youtube-main.js and prepublish-extension/src/content/youtube.js.
Cost and breakage:
- It needs a real browser with the video open. There is no headless path, and row five is why.
- The token only exists after the player requests a caption body, which requires a track to be switched on, so the script drives
setOption('captions', 'track', ...)instead of clicking the CC button, which is not always present. - A pre-roll ad owns the player. Until the ad finishes,
getOption('captions', 'tracklist')returns an empty list and the video's own caption tracks do not exist yet. - The captured URL arrives from the page, which the page can also write to. Validate the protocol, the host, and the exact path before storing or fetching it.
| Path | Time |
|---|---|
| Active tab, caption URL already captured | 1.4 s |
| Active tab, cold, no pre-roll | 14 s |
| Active tab, cold, with pre-roll | 17 to 28 s |
| Hidden background tab | 26 to 38 s |
| Hidden tab, when caption tracks first appear | about 18 s after load |
Those timings were measured against live YouTube on 18 and 19 September 2026.
Approach 2: yt-dlp with curl_cffi
yt-dlp does not replay a browser request. It succeeds for a different reason: the caption body request goes out through curl_cffi, which presents a browser TLS fingerprint, and the timedtext endpoint answers HTTP 429 to requests without one. A build that lacks curl_cffi therefore fails captions while metadata keeps working. The server-side implementation is in prepublish-be/internal/infrastructure/youtube/channel_dlp.go.
Measured on the production host on 19 September 2026:
| Container | yt-dlp | curl_cffi | Proxy | Canary result |
|---|---|---|---|---|
| API | 2026.08.19 | 0.16.3 | set | ok, 18,143 caption characters |
| Worker | 2026.08.19 | missing | missing | blocked: "Sign in to confirm you're not a bot" |
| Worker with proxy only | 2026.08.19 | missing | set | broken: metadata fine, 0 caption characters, yt-dlp warned that mweb client subtitles require a PO token |
| Worker image plus curl_cffi plus proxy | 2026.08.19 | 0.16.3 | set | ok, 18,143 caption characters |
Without curl_cffi, yt-dlp cannot impersonate a browser TLS fingerprint, falls back to the mweb client for subtitles, and that client needs a PO token, so the caption download returns nothing. A residential proxy alone does not fix it. Neither does a newer yt-dlp.
The costs are a Python environment that can carry the curl_cffi wheel, an egress proxy if you run from a datacentre address, and upkeep that includes release cadence. The current stable release was 2026.8.19, 30 days old on 18 September 2026, while the newest nightly on PyPI was 2026.9.16.232951.dev0 at 1.8 days old, with a median gap of 1.5 days between the 15 most recent PyPI releases.
Server-side timings, measured 18 and 19 September 2026: a cold pull takes 37.1 s and a cached read takes 4 ms. Three consecutive cached reads measured 3.9 ms, 4.4 ms, and 3.5 ms, and ten concurrent cold requests for one video produced exactly one yt-dlp run, one stored row, and ten identical bodies. Source: prepublish-be/internal/domain/service/video_transcript_service.go.
Where this breaks: the plain zip-app build from GitHub releases cannot bundle curl_cffi, so it cannot fetch captions at all, and the failure arrives as a 429 that reads like a network block.
Approach 3: the official Data API and its ownership requirement
YouTube's own captions.download method requires the user to have permission to edit the video, accepts the youtube.force-ssl or youtubepartner scope, and costs 200 quota units per call. A request without sufficient permission returns 403 forbidden (Google's captions.download reference). For a video you own or manage, this is the documented and stable route.
For another creator's public video it is not an option. The permission the method requires is edit access to the video, and there is no scope an ordinary developer can request that substitutes for it.
Why the user agent matters: the HeadlessChrome measurement
The token-bearing URL returned HTTP 200 with a zero-length body under a HeadlessChrome user agent, and the full JSON3 body after the same session switched to a normal Chrome user agent. That pair was measured on 18 September 2026 and the two requests differed in nothing else.
Two caveats belong here. This is an observation of behaviour, not documented policy: YouTube does not publish the rule that produces it, so nothing here can say whether the user agent is used alone or as one signal among several. It is also not a substitute for the token. Rows one and two were sent a token-less URL from real Chrome with a normal user agent and returned zero bytes, so a request does not start working when it claims to be a browser. It starts working when it carries the token, and the user agent gate sits on top of that.
Limits: undocumented behaviour that needs a canary
Nothing here is contractual. The timedtext endpoint has no public documentation, the token handshake is an implementation detail, and YouTube can change any part of it without notice. Detection happens when a pull breaks, so anything built on this needs a canary that exercises the production path.
A working design probes one public video twice: metadata with a non-empty title, and captions with a text floor. prepublish-be/scripts/ytdlp-canary.sh uses that shape. It asks for the en track the way production does, so a machine translation cannot satisfy the check. The caption prose must reach 200 characters, and the probe reports ok, blocked, or broken with exit codes 0, 2, and 1. The distinction matters because the fixes are opposite. blocked means YouTube refused the egress IP, and a proxy or cookies file is the fix. broken means the binary or the extractor is wrong, and a proxy will change nothing. The same script checks for the missing-impersonation warning before either, because a 429 from a build without curl_cffi arrives in the same shape as a network block and is not one.
That canary is exposed as GET /health/ytdlp, which answers 200 while the probe passes and 503 for blocked, broken, or unknown, with an in-process run every 30 minutes (prepublish-be/internal/application/handler/health_handler.go). The binary refreshes nightly with a canary and rollback (prepublish-be/scripts/ytdlp-update.sh, scheduled by ops/systemd/prepublish-ytdlp-update.timer at 03:30 UTC), and the container image pin is bumped weekly.
Two limits sit below the canary. A canary video can pass while a specific video fails, because that track may be absent, restricted, or in a language the request did not name. The browser path adds a second: while a pre-roll ad is playing, the caption tracks do not exist yet.
If the goal is a transcript rather than a working scraper, the transcript endpoint runs the yt-dlp path server-side and keeps the result for 30 days. That turns the 37.1 s cold pull into a stored row that reads in single-digit milliseconds (prepublish-be/internal/domain/service/video_transcript_service.go). The same pull is reachable from the free tools and over MCP at the MCP server, for callers that would rather not run yt-dlp themselves.
Public videos only
Everything above concerns caption tracks that a creator published on a public video and that the player serves to any signed-out viewer. None of the measurements came from private, members-only, or otherwise restricted content.
If a track is not served to a signed-out browser, there is no player request to observe and nothing to replay. Reaching one would mean working around an access control, which is a different problem from the one this article describes.
The endpoint never stopped serving captions to the player. It stopped serving them to callers without the player's token, so the failure is in who the request comes from, not in whether the video has captions.
Frequently asked questions
Why does the YouTube timedtext endpoint return 200 with an empty body?
The request is valid but it does not carry a proof-of-origin token, which YouTube mints inside the player while a video is playing. The endpoint answers HTTP 200 because nothing about the request is malformed, then returns zero bytes because the token check does not pass. Fetching captionTracks[].baseUrl from ytInitialPlayerResponse returned HTTP 200 with body length 0, measured in real Chrome on 18 September 2026. Adding cookies or requesting from the page origin did not change the result.
Does adding fmt=json3 fix an empty timedtext response?
No, and it is worth separating the two parameters. Without the token, a request to the same URL returns an empty body with or without a fmt parameter. Measured on 18 September 2026, adding &fmt=json3 to the player's own token-bearing URL returned 46,010 bytes on a 19-minute video and 85,495 bytes on another. The format parameter chooses the shape of the body, and the token decides whether there is a body at all.
What is a pot token in a YouTube caption request?
It is a proof-of-origin token that the player mints and attaches to its own caption request. Measured on 18 September 2026, the URL the player requests carries &potc=1&pot=<token>&key=yt8&lang=en&fmt=json3. The value does not appear in the page HTML, it is not part of ytInitialPlayerResponse, and it is not derivable from either, which is why copying the baseUrl out of the player response produces an empty body.
Why does yt-dlp return metadata but no captions?
The caption body is fetched separately from metadata and must present a browser TLS fingerprint, which requires the curl_cffi dependency. Without it, yt-dlp falls back to the mweb client for subtitles, and that client needs a PO token, so the download returns nothing. Measured on 19 September 2026, a worker container with yt-dlp 2026.08.19 and no curl_cffi returned metadata but 0 caption characters, and adding a residential proxy did not change that.
Can the official YouTube Data API download captions from another channel?
Not for videos you do not own. The captions.download method requires the user to have permission to edit the video, and it accepts the youtube.force-ssl or youtubepartner scopes at a quota cost of 200 units per call. A request without sufficient permission returns 403 forbidden. For your own uploads it is the correct and stable route. For other people's public videos it is not an option.
Does changing the user agent fix an empty timedtext response?
It is one measured signal, not a fix. On 18 September 2026, the same token-bearing URL returned HTTP 200 with a zero-length body under a HeadlessChrome user agent and the full JSON3 body after switching to a normal Chrome user agent. That was measured in a controlled browser session, and it is an observation of behaviour rather than documented policy. A token-less request returned zero bytes in a real Chrome, so no user agent string replaces the token.
How do I know when YouTube changes this behaviour?
Run a canary that exercises the same path production uses and distinguishes an IP block from a broken build. A working design probes one public video twice, checking for a non-empty title and a caption text floor of 200 characters, and reports blocked when YouTube refuses the egress IP and broken when the tooling itself is at fault. The two failures need opposite fixes, so a canary that reports only pass or fail sends you to the wrong layer.
Does this apply to private or members-only video captions?
No. Everything here concerns caption tracks a creator published on a public video and that the player serves to any signed-out viewer, and none of the measurements came from restricted content. If a track is not served to a signed-out browser, there is no player request to observe and nothing to replay. Reaching one would mean working around an access control, which is out of scope.
Related Guides
Free tools to put this into practice
Hook Analyzer
Score your first 1-3 sentences
Title Analyzer
Writing rubric + 5 rewrites
Words to Minutes
Script length calculator
Word Counter
Count, reading time, duration
Want to see how this reads on real channels? Browse the channel breakdowns. Each one compares script patterns across a channel's own higher-viewed and lower-viewed uploads, quoted from the transcripts.
See where your next script leaks viewers
Paste your script, get your scores and the biggest leak for free. No login.