You can fetch YouTube transcripts in Node.js with a library that reads captions directly, or with a hosted API. The difference matters once the code leaves your laptop: caption availability, blocked requests, batch failures, and audio transcription all need explicit handling.
TranscriptFetch’s YouTube Transcript API provides transcript retrieval, channel listing, and batching through the official transcriptfetch package. This guide uses the published Node SDK 2.3.0 and the v2 API. The examples cover a single video, a batch with partial failures, and a bounded channel import. An open-source alternative follows at the end.
TL;DR: Use TranscriptFetch’s Node SDK to fetch video transcripts, process batches, and enumerate channel videos without a Google API key. Handle pending audio jobs and per-video failures explicitly, and derive plain text from segments when needed. With SDK 2.3.0, single-video mode and timestamps options go through request(), not the video() helper.
YouTube transcript API in Node.js: choose your approach
| Capability | Open-source caption library | TranscriptFetch |
|---|---|---|
| Authentication | No TranscriptFetch key | One TranscriptFetch API key |
| Existing captions | Reads YouTube’s unofficial endpoints | Fetches captions through the hosted API |
| No caption track | Needs a separate transcription system | Automatic audio fallback, or captions-only mode |
| Channel enumeration | Separate listing implementation | Paginated channel endpoint and SDK iterator |
| Batch retrieval | Build your own queue | Batch endpoint with per-video outcomes |
| Language selection | The library exposes a language option | No request parameter for selecting a transcript language |
| Operational work | Handle upstream changes and deployment blocking | Handle documented errors, limits, and pending jobs |
A caption library is useful for a local script or an experiment. A hosted service fits applications that need the retrieval work maintained separately from their own code. Neither approach guarantees access to private, deleted, or restricted videos.
Install the Node SDK and set your key
Use a currently supported Node.js release. The SDK’s minimum is Node 18, but that minimum is not a recommendation to start a new deployment on an old release. These TypeScript examples run with tsx, so no special ESM or ts-node configuration is needed.
npm install transcriptfetch@2.3.0
npm install --save-dev tsx typescript @types/node
export TRANSCRIPTFETCH_API_KEY='tf_live_YOUR_KEY'Create your key in the dashboard. Keep it on your server: do not put it in browser code or a public frontend environment variable. You do not need a YouTube Data API key for the TranscriptFetch examples.
The Node SDK documentation explains the client, and the SDK source defines its released methods. Pinning the version here makes the examples reproducible.
Fetch one transcript, including pending audio jobs
A video with an accessible caption track can return immediately. When captions are unavailable, the default mode can transcribe the audio. Short media may finish during the request; other requests return a pending job. Treating every successful HTTP response as finished text loses that distinction.
Save this shared client as client.ts. The polling helper stops after a bounded wait, checks failed jobs, and retains the job ID in its timeout error. The server job continues if the script stops waiting.
// client.ts
import { TranscriptFetch, type Transcript } from "transcriptfetch";
if (!process.env.TRANSCRIPTFETCH_API_KEY) {
throw new Error("Set TRANSCRIPTFETCH_API_KEY");
}
// The key is read from TRANSCRIPTFETCH_API_KEY automatically.
// Allow enough time for the API's inline audio-transcription wait.
export const tf = new TranscriptFetch({ timeout: 60_000, maxRetries: 2 });
export function transcriptText(result: Transcript): string {
return result.text ?? result.segments.map(segment => segment.text).join(" ");
}
export async function waitForTranscript(
initial: Transcript,
maxWaitMs = 120_000,
): Promise<Transcript> {
let result = initial;
const deadline = Date.now() + maxWaitMs;
while (result.status === "processing") {
if (!result.jobId) throw new Error("Pending response has no job ID");
if (Date.now() >= deadline) {
throw new Error(`Still processing; resume polling job ${result.jobId}`);
}
await new Promise(resolve => setTimeout(resolve, 2_000));
const job = await tf.transcripts.job(result.jobId);
if (job.status === "failed") {
throw new Error(job.error?.message ?? `Job ${result.jobId} failed`);
}
result = job;
}
if (result.status === "failed") throw new Error("Transcription job failed");
return result;
}Then save and run the single-video example:
// single.ts
import { tf, transcriptText, waitForTranscript } from "./client";
async function main() {
const result = await waitForTranscript(
await tf.transcripts.video("https://youtu.be/aircAruvnKk"),
);
console.log(result.title);
console.log(transcriptText(result));
for (const segment of result.segments) {
console.log(segment.start, segment.duration, segment.text);
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});npx tsx single.tsThe default single-video HTTP response contains segments, not a second copy of the transcript in text. SDK 2.3.0 normalizes missing text to null, so the helper joins the segments when necessary. Segment start times and durations are in seconds. Batch entries and completed job results can carry both representations.
Caption retrieval costs one credit per successful result. Audio transcription costs one credit per started five minutes, with a minimum of one, charged when the result is delivered. Polling a job is free. See billing and AI transcription before importing a large collection.
Request plain text or captions-only retrieval
SDK 2.3.0’s video() helper does not accept mode, timestamps, or callbackUrl. Use tf.request() for these API options, with the API’s snake_case field names. This still uses the SDK’s authentication, HTTP retries, and idempotency support.
This example explicitly selects captions-only mode. It returns finished text or throws an API error; it never starts an audio job.
// plain.ts
import { tf } from "./client";
async function main() {
const envelope = await tf.request("POST", "/api/v2/transcripts/video", {
body: {
video: "https://youtu.be/aircAruvnKk",
mode: "captions",
timestamps: false,
},
idempotent: true,
});
const data = envelope.data as { text: string };
console.log(data.text);
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});npx tsx plain.tsFor reference, the underlying HTTP request is:
curl https://transcriptfetch.com/api/v2/transcripts/video \
-H "Authorization: Bearer $TRANSCRIPTFETCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"video":"aircAruvnKk","mode":"captions","timestamps":false}'Raw HTTP and request() return an envelope containing ok, request_id, data, and usage information. Read data.text, not top-level text. Resource helpers such as video() unwrap that envelope and normalize names such as video_id to videoId.
If you change this request to mode: "auto" or mode: "audio", also handle the possible 202 job response. A callback uses the field callback_url. The endpoint does not expose a language selector; language in the response reports the language retrieved or detected. The endpoint reference is the full wire contract.
Batch transcripts with partial results
Use the batch endpoint instead of sending an unbounded Promise.all of single-video requests. The API accepts up to 50 entries on free, Basic, and Pro plans, or 500 on Mega and Scale. These examples use chunks of 50 so the same code fits every plan.
Save this reusable function as batch.ts. Captions-only mode makes the import’s behavior explicit: videos without an accessible caption track fail as individual entries, rather than starting audio transcription.
// batch.ts
import { tf } from "./client";
export async function fetchBatch(videoIds: string[]) {
const batch = await tf.transcripts.batch(videoIds, { mode: "captions" });
for (const result of batch.results) {
if (result.outcome === "ok") {
const text = result.text ?? result.segments?.map(s => s.text).join(" ") ?? "";
console.log(result.videoId, text);
} else if (result.outcome === "processing") {
console.log(result.videoId, "Pending job:", result.jobId);
} else {
console.error(result.videoId, result.error?.code, result.error?.message);
}
}
return batch;
}// run-batch.ts
import { fetchBatch } from "./batch";
fetchBatch(["aircAruvnKk", "dQw4w9WgXcQ"]).catch(error => {
console.error(error);
process.exitCode = 1;
});npx tsx run-batch.tsThe processing branch becomes relevant if you switch the batch mode to auto. Captionless entries can then include jobId; persist those IDs and poll tf.transcripts.job(), or re-submit the batch after processing finishes. Do not count a pending entry as delivered text. Request-level failures, such as an invalid key or an oversized batch, still reject the call and need their own error handler.
Fetch transcripts from a YouTube channel
tf.transcripts.iterChannel() walks paginated channel metadata. Feed the returned video URLs or IDs into the batch function. Channel listing itself does not return transcripts, and it does not require a Google API key.
This example deliberately stops at 100 videos to bound a first run. A run of 100 caption transcripts plus listing pages exceeds the initial 50-credit free allowance, so lower the cap for a smaller trial or ensure your balance covers it. Increase the cap when you have checked results and credit usage. It processes one batch at a time, flushes the final partial batch, and does not hold the entire channel in memory.
// channel.ts
import { tf } from "./client";
import { fetchBatch } from "./batch";
async function importChannel(channel: string, maxVideos = 100) {
if (maxVideos <= 0) return;
let pending: string[] = [];
let seen = 0;
for await (const video of tf.transcripts.iterChannel(channel, { limit: 50 })) {
pending.push(video.url ?? video.videoId);
seen += 1;
if (pending.length === 50) {
await fetchBatch(pending);
pending = [];
}
if (seen >= maxVideos) break;
}
if (pending.length) await fetchBatch(pending);
}
importChannel("@GoogleDevelopers", 100).catch(error => {
console.error(error);
process.exitCode = 1;
});npx tsx channel.tsFor a resumable importer, store the cursor from tf.transcripts.channel() and record each delivered video ID. Non-empty listing pages cost one credit, separately from transcripts. Channel filters such as since_video_id require request() in SDK 2.3.0. See pagination and the channel transcript guide.
The open-source alternative
The youtube-transcript package reads YouTube captions through unofficial endpoints. It requires no TranscriptFetch account and exposes a language option. It does not provide TranscriptFetch’s audio fallback, hosted batch processing, or channel iterator.
npm install youtube-transcript@1.3.1// single-oss.ts
import { fetchTranscript } from "youtube-transcript";
async function main() {
const items = await fetchTranscript("aircAruvnKk", { lang: "en" });
if (!items.length) throw new Error("No caption fragments returned");
console.log(items.map(item => item.text).join(" "));
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});npx tsx single-oss.tsThe library’s maintainer warns that upstream changes can break retrieval. An unavailable English track is not fixed by retrying the same language repeatedly, and more concurrency does not fix a blocked deployment IP. Test the library from the environment where your application will run. For an OSS channel workflow, you also need a listing source; Google’s uploads-playlist pattern is one option and needs separate Google credentials.
Handle failures before storing results
The SDK retries HTTP 429 and 5xx responses, honoring Retry-After, and reuses an idempotency key across those HTTP retries. Connection and timeout errors still need application handling. Do not assume that calling the helper again later automatically reuses the previous call’s key.
Authentication and credits: Fix
401or402conditions before retrying.Rate limits: Respect backoff and reduce simultaneous batches if you receive
429responses.Missing or inaccessible content: Inspect the error code. Retrying a deleted video will not make it available.
Pending audio: Save the job ID and wait for a completed result. Failed jobs return a failure status when polled.
Storage: Save the source URL, video ID, language, and segments. Derive plain text locally when the single-video response only includes segments.
API exceptions expose a status, code, and request ID. Keep the request ID in logs so a failed import can be investigated. The error reference describes the outcomes; the YouTube MCP Server exposes retrieval to agent clients when that is the integration you need.
