Skip to content

Minting YouTube PO Tokens in Node: Fixing 0-Byte Caption Responses

YouTube caption URLs answer HTTP 200 with an empty body. Here is why, and the working Node recipe — including the three details nobody wrote down.

14 min read · 20 Aug 2026

You ask YouTube for a caption track. You get this:

HTTP/1.1 200 OK
content-type: text/xml; charset=utf-8
content-length: 0

Two hundred. Not 403, not 429, not 404. A successful response containing nothing at all.

If you are here, you have probably already spent an afternoon assuming the video has no captions. It does — you can watch them render in the player. You have probably also tried &fmt=json3, then srv1, then srv3, then vtt, and gotten zero bytes from every one of them. And you may have found jdepoix/youtube-transcript-api issue #592, where the maintainer of the most-used Python library for this raises a PoTokenRequired exception and states there is no workaround available.

This post is the workaround. It is roughly 120 lines of Node, it works against live traffic as of 2026-08-20, and three of its details cost us days because they are not written down anywhere we could find. Everything is here — the code, the measurements, and the parts that will break first.

If you want the wider context on what YouTube gives up without an API key at all — RSS, oEmbed, the ungated caption path — that lives in the no-API-key YouTube reference, and the dated status table for every platform we test is in what you can actually get without an API key. This post is only about the gate.


The symptom, precisely

Caption tracks come from the watch page. YouTube embeds a JSON blob called ytInitialPlayerResponse in the HTML of https://www.youtube.com/watch?v=<id>, and inside it:

captions.playerCaptionsTracklistRenderer.captionTracks[]

Each entry has a baseUrl, a languageCode, and a kind (asr for auto-generated). Fetch the baseUrl and you get captions. That is how this worked for a decade.

Now look at the baseUrl itself. Ours carried a query parameter that is easy to scroll past:

&exp=xpe

Every track whose URL carries exp=xpe behaves like this:

Request Status Bytes
baseUrl (no fmt) 200 0
baseUrl&fmt=json3 200 0
baseUrl&fmt=srv3 200 0
baseUrl&fmt=srv1 200 0
baseUrl&fmt=vtt 200 0

The format parameter is irrelevant. The gate sits upstream of formatting.

This is a deliberate design choice and it is worth naming as such: a quiet failure that is indistinguishable from a legitimate empty result. A 403 would tell your error handler what happened. A 200 with no body tells it the video has no captions, which is a perfectly ordinary thing for a video to have. Client libraries then report "no transcript available," and the bug lands on you rather than on YouTube. The general version of this pattern is why an API answers 200 OK when it means no; the caption endpoint is its purest example.


Everything that does not work

Before the fix, the failures — because knowing what to skip is worth as much as knowing what to do.

Approach Result
InnerTube /youtubei/v1/player, WEB client 200, but no captions object at all
InnerTube /youtubei/v1/player, ANDROID client 400
InnerTube /youtubei/v1/get_transcript, 6 panel continuation tokens 400 on all six
Same, with a full set of InnerTube headers 400 on all six
Any fmt variant on the gated baseUrl 200 / 0 bytes

A note on the InnerTube WEB player response, because it is the most misleading of these. It does not error. It returns a complete, valid player response that simply has no captions key. If you are writing defensive code you will treat that as "this video has no captions" and move on. It is not that. It is the same gate, expressed as an omission instead of an empty body.

The ANDROID client route is recommended in a lot of older threads and it is dead: 400, immediately. Don't spend time there.

Why porting from Python gains you nothing

The natural instinct when a Node implementation fails is to look at how the mature Python library does it. In this case the mature Python library does not do it either.

youtube-transcript-api added the PoTokenRequired exception precisely because it cannot generate one, and disabled its cookie-auth path in the same period. yt-dlp hit the identical wall in issue #13075, and its answer is an external BotGuard provider plugin — which starts a Node.js server and shells out to it.

That is the whole asymmetry, and it is why this is a short post rather than a research project. The thing that generates the token is JavaScript. Python implementations have to leave the language to get one. If your service is already Node, you are the runtime everyone else is subprocessing to.


What the gate actually is

Since 2025 YouTube requires a proof-of-origin token — a pot parameter — on caption tracks carrying exp=xpe. The token comes from BotGuard, Google's browser-attestation VM. BotGuard ships as obfuscated JavaScript, runs in the page, inspects the environment it finds itself in, and produces a snapshot that Google's servers exchange for an integrity token. The PO token is then minted locally from that integrity token, bound to a specific string.

Two properties matter:

  1. The integrity token has a long life. Ours came back with a TTL of 43,200 seconds — twelve hours. One expensive operation covers half a day of requests.
  2. The mint is local. Once you hold the integrity token, producing a PO token for a specific video is a function call with no network round trip.

So the cost model is not "one BotGuard run per transcript." It is one BotGuard run per twelve hours, amortised across everything you fetch in that window.


The fix

The library that does the hard part is bgutils-js by LuanRT, who tracks BotGuard changes closely. Pair it with jsdom, because BotGuard's interpreter expects to find a browser.

npm install bgutils-js@^4.0.3 jsdom@^30

Node 22 or later. fetch is global; you do not need an HTTP client for this file.

The flow, end to end

1. jsdom → install window/document/navigator on globalThis
2. getChallenge({ requestKey: 'O43z0dpjhgX20SCx4KAo' })
3. new Function(challenge.interpreterJavascript)()   ← VM registers on globalThis
4. BotGuardClient.create({ program, globalName, globalObject: globalThis })
5. client.snapshot({ webPoSignalOutput })            ← VM populates the array
6. POST jnn-pa.googleapis.com/$rpc/.../GenerateIT    → integrity token, TTL 43200s
7. WebPoMinter.create({ integrityToken }, webPoSignalOutput)
8. minter.mintAsWebsafeString(videoId)               → PO token
9. GET <baseUrl>&fmt=json3&pot=<token>&c=WEB         → 8,325 bytes of json3

Steps 1–7 are the expensive half and run once per twelve hours. Steps 8–9 run per request.

The minter

import { BotGuardClient, getChallenge } from 'bgutils-js/botguard';
import { WebPoMinter } from 'bgutils-js/webpo';
import { buildURL, getHeaders } from 'bgutils-js/utils';

/** YouTube's public BotGuard request key. Stable, and not a secret. */
const REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';

let state = null;      // { minter, client, expiresAt }
let inFlight = null;   // collapses concurrent initialisation
let domReady = false;

/**
 * BotGuard's interpreter expects a browser. jsdom supplies window/document/
 * navigator, and they must live on globalThis, because the interpreter
 * attaches its entry point to the global scope when it is evaluated.
 */
async function ensureDom() {
  if (domReady) return;

  const { JSDOM } = await import('jsdom');
  const dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>', {
    url: 'https://www.youtube.com/',
    referrer: 'https://www.youtube.com/',
  });

  globalThis.window = dom.window;
  globalThis.document = dom.window.document;
  globalThis.location = dom.window.location;
  globalThis.origin = dom.window.origin;

  // navigator is read-only on modern Node, so define rather than assign.
  if (!globalThis.navigator) {
    Object.defineProperty(globalThis, 'navigator', {
      value: dom.window.navigator,
      configurable: true,
    });
  }

  domReady = true;
}

async function createMinter() {
  await ensureDom();

  const challenge = await getChallenge({ requestKey: REQUEST_KEY, fetchFunction: fetch });

  const interpreter =
    challenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
  if (!interpreter) throw new Error('BotGuard challenge contained no interpreter script');

  // Evaluate the VM. It registers itself on globalThis under challenge.globalName.
  new Function(interpreter)();

  const client = await BotGuardClient.create({
    program: challenge.program,
    globalName: challenge.globalName,
    globalObject: globalThis,
  });

  // The VM writes its minter factory into this array as a side effect.
  const webPoSignalOutput = [];
  const botguardResponse = await client.snapshot({ webPoSignalOutput });

  const res = await fetch(buildURL('GenerateIT', false), {
    method: 'POST',
    headers: getHeaders(),
    body: JSON.stringify([REQUEST_KEY, botguardResponse]),
  });
  if (!res.ok) throw new Error(`GenerateIT responded ${res.status}`);

  // Protobuf-over-JSON: [integrityToken, ttlSecs, refreshThreshold, fallback]
  const [integrityToken, ttlSeconds = 3600] = await res.json();
  if (!integrityToken) throw new Error('GenerateIT returned no integrity token');

  const minter = await WebPoMinter.create(
    { integrityToken, estimatedTtlSecs: ttlSeconds },
    webPoSignalOutput,
  );

  // Refresh at 80% of TTL so we never serve on a token about to expire.
  const expiresAt = Date.now() + ttlSeconds * 1000 * 0.8;

  // Do NOT call client.shutdown() here. See gotcha 3 below.
  return { minter, client, expiresAt };
}

export async function mintPoToken(videoId) {
  if (!state || state.expiresAt <= Date.now()) {
    const previous = state;
    inFlight ??= createMinter()
      .then((next) => {
        // Retire the superseded VM only once its replacement is live.
        if (previous) void previous.client.shutdown().catch(() => {});
        state = next;
        return next;
      })
      .finally(() => { inFlight = null; });
    await inFlight;
  }
  return state.minter.mintAsWebsafeString(videoId);
}

Using it

/** True when a caption URL is PO-gated. */
export function requiresPoToken(url) {
  return /[?&]exp=xpe\b/.test(url);
}

export async function fetchTranscript(videoId) {
  // 1. Pull the caption track list off the watch page.
  const html = await fetch(`https://www.youtube.com/watch?v=${videoId}`, {
    headers: {
      // Skips the EU consent interstitial, which otherwise replaces the payload.
      cookie: 'CONSENT=YES+cb; SOCS=CAI',
      'accept-language': 'en-US,en;q=0.9',
    },
  }).then((r) => r.text());

  const player = extractJson(html, 'ytInitialPlayerResponse');
  const tracks = player?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
  if (tracks.length === 0) throw new Error('This video has no captions.');

  // 2. Prefer an ungated track if one exists — it costs nothing to fetch.
  const ungated = tracks.filter((t) => !requiresPoToken(t.baseUrl));
  const pool = ungated.length > 0 ? ungated : tracks;
  const track = pool.find((t) => t.kind !== 'asr') ?? pool[0];

  let url = `${track.baseUrl}&fmt=json3`;

  // 3. Gated → mint a VIDEO-bound token and attach it, with c=WEB.
  if (requiresPoToken(track.baseUrl)) {
    const token = await mintPoToken(videoId);
    url += `&pot=${encodeURIComponent(token)}&c=WEB`;
  }

  const body = await fetch(url).then((r) => r.text());
  if (body.length === 0) throw new Error('Empty caption body even with a PO token.');

  // 4. json3 → cues.
  const { events = [] } = JSON.parse(body);
  return events
    .filter((e) => Array.isArray(e.segs))
    .map((e) => ({
      start: (e.tStartMs ?? 0) / 1000,
      text: e.segs.map((s) => s.utf8 ?? '').join('').replace(/\n/g, ' ').trim(),
    }))
    .filter((c) => c.text.length > 0);
}

extractJson is a brace-counting extractor for var ytInitialPlayerResponse = {...}; — not a regex, because the payload contains braces inside strings and a lazy regex truncates it. Track string and escape state as you count and it is twenty lines.


The three things that actually cost us days

Everything above is more or less what you would guess from reading the bgutils-js README. These three are not, and each of them fails in a way that gives you no signal at all.

1. &c=WEB is mandatory

This is the single most important line on this page.

With a perfectly valid, correctly bound PO token appended as &pot=..., and no client parameter, the timedtext endpoint still returns 200 with 0 bytes.

Identical symptom to having no token whatsoever. Nothing in the response distinguishes "your token is wrong" from "you did not say which client you are." We spent real time re-minting tokens, re-checking the binding, and reading BotGuard internals, when the actual problem was a missing four-character query parameter.

&pot=<token>            → 200, 0 bytes
&pot=<token>&c=WEB      → 200, 8,325 bytes

If you take one thing from this post, take that.

2. Bind to the video ID, not visitorData

PO tokens are bound to a string, and which string you pick depends on the context you are using the token in. Most published examples bind to visitorData — the session identifier YouTube assigns a browser — because most published examples are about video playback.

Captions are a different context. yt-dlp calls it the subs context, and it wants the video ID.

We confirmed this empirically: with everything else held constant, a visitorData-bound token returns an empty caption body and a video-ID-bound token returns the transcript. Same failure signature as gotcha 1, which is exactly what makes the two so easy to conflate while debugging. Change one variable at a time here.

minter.mintAsWebsafeString(videoId);        // captions ✅
minter.mintAsWebsafeString(visitorData);    // captions ❌ — 200, 0 bytes

3. Never shut the BotGuard VM down after minting

This one is genuinely nasty.

The obvious hygiene after WebPoMinter.create() is to tear down the VM you just used. You have your minter, the VM has served its purpose, call client.shutdown() and free it.

Do not. WebPoMinter's mint callback closes over the running BotGuard VM. Shut the VM down and the minter object survives, looks fine, and fails on every subsequent call with:

YNJ:Undefined

That error string is your only clue, it is not documented, and it surfaces nowhere near the shutdown() call that caused it. If you cached the minter and disposed of the client — which is exactly what a careful reviewer would suggest in a pull request — your first mint succeeds and everything after it fails.

The VM must outlive the minter. Retire it only when its replacement is already live:

inFlight ??= createMinter().then((next) => {
  if (previous) void previous.client.shutdown().catch(() => {});  // the OLD vm, not the new one
  state = next;
  return next;
});

Measured

Against live traffic on 2026-08-20:

Metric Value
Cold mint (challenge + VM + GenerateIT) ~1.2 s
Cached mint 0 ms
Integrity token TTL 43,200 s (12 h)
Refresh threshold 80% of TTL
Transcript payload 8,325 bytes of json3
End-to-end transcript request ~2.4 s

The 2.4 seconds is a watch-page fetch plus a timedtext fetch, and in the steady state the mint contributes 0 ms of it. There is no proxy in this path and none is needed — YouTube serves all of it to a plain datacenter IP. That single fact is why transcripts are close to free to run, and it is the technical basis for the argument that marginal cost in this category varies by two orders of magnitude between platforms.


Operating it

A working mint is not the same as a working service. Four behaviours matter once this handles real traffic.

Collapse concurrency. A burst of ten simultaneous transcript requests on a cold start must run one BotGuard VM, not ten. jsdom plus an obfuscated interpreter is not cheap, and ten of them arriving together is how a 1.2-second operation becomes a 30-second one. The inFlight ??= promise above is the whole mechanism.

Back off on failure. Consecutive mint failures should back off exponentially — we use 30 s doubling to a 15-minute cap. BotGuard has bad minutes. Without a cooldown, a bad minute becomes a hot loop against Google's attestation endpoint, which is a reliable way to turn a temporary problem into a permanent one.

Retry an empty body exactly once. A token can go stale mid-flight, and your exp=xpe check can miss a gating variant. If the body comes back empty, force one re-mint and try again — then give up. Not twice, not with a backoff loop. A second empty body means something structural changed, and retrying will not find it.

Decide the billing question before the outage, not after. If minting fails, the request produced nothing. In our pipeline that surfaces as an upstream_blocked error with reason: po_token_rejected, and the caller is never charged for it. Whatever your equivalent is, settle it now rather than after a BotGuard outage generates a day of invoices for zero bytes.

Finally, expose the state. Ours reports on /status:

{
  "youtubePoToken": {
    "hasActiveToken": true,
    "expiresInSeconds": 31284,
    "failureStreak": 0,
    "cooldownRemainingSeconds": 0
  }
}

failureStreak rising above zero is the earliest warning you will get that Google changed something.


What will break this, and how you will know first

Be honest about the shelf life. This is the most fragile thing in our codebase, and it is fragile by design — Google iterates on BotGuard deliberately, and the gate exists to be moved.

Three things are load-bearing and none of them is a contract:

  • The request key O43z0dpjhgX20SCx4KAo is public and currently stable. It is not guaranteed.
  • The exp=xpe marker is how we decide a track is gated. If YouTube renames the experiment flag, ungated tracks start taking the expensive path and gated ones stop being detected. Both changes are silent.
  • The c=WEB requirement was discovered empirically. It could gain a sibling parameter tomorrow, and the failure will again be 200 with no body.

Your monitoring signal is a rising mint-failure streak plus a rising rate of empty bodies on tracks you believed were ungated. Alert on both. When it does break, update bgutils-js first — LuanRT tracks BotGuard changes far more closely than you will.


What we will not do

We will not fall back to cookie authentication, and you should think carefully before you do either.

Logging in to fetch public captions moves you out of "public, logged-out data" and into a relationship governed by an accepted terms-of-service agreement. That is a materially different posture — it is the distinction that did most of the work in Meta v. Bright Data — and it is part of why youtube-transcript-api disabled its own cookie path rather than leaning on it harder. The PO-token route is more work and it keeps you logged out.

That is a position, not legal advice. But it is why the code above never sends a session cookie.


Summary

  • Caption URLs carrying exp=xpe need a proof-of-origin token. Without one: 200, zero bytes, every format.
  • The token comes from BotGuard, which is JavaScript. Node hosts it natively; Python has to shell out to Node to get one.
  • &c=WEB is mandatory. A valid token without it still returns nothing.
  • Bind the token to the video ID, not visitorData.
  • Never call client.shutdown() after minting — the minter closes over the running VM, and you get YNJ:Undefined forever after.
  • One mint costs ~1.2 s and covers 12 hours. Marginal cost per transcript is two plain HTTP fetches and no proxy.

The implementation we run is src/scrapers/youtube/potoken.ts, and it is the code above plus types, logging, and the status hook. If you would rather not own a BotGuard VM's lifecycle, our GET /v1/youtube/transcript endpoint does exactly this and does not charge you when the mint fails — but the recipe here is complete, and you do not need us to use it.

Everything described here runs on the same API. You are never charged for a failed request, an empty result, or a cache hit.