How to extract YouTube transcripts in production (cURL, Python, and JavaScript)
You have a list of YouTube URLs. You need the transcript text from each one. And there's no way you're going to copy and paste from the YouTube player 500 times.
Programmatic transcript extraction is the answer. It's how developers build video summarizers, collect AI training data, create accessibility tools, and run content analysis at scale. One HTTP request per video. Structured text data back in milliseconds.
This tutorial walks you through three complete methods: a quick cURL command for testing, a production-ready Python script with batch processing, and a JavaScript implementation for both Node.js and the browser. Each one uses the TranscriptAPI REST endpoint. Pick the one that fits your stack and run with it.

Prerequisites and setup
What you need before starting
You don't need much:
A TranscriptAPI account (the free tier gives you 100 credits to follow along with every example in this tutorial)
Python 3.8+ or Node.js 18+ installed, depending on which code examples you want to run
Basic familiarity with REST APIs and HTTP requests
That's it. No OAuth setup. No Google Cloud Console project. No pip packages that break whenever YouTube pushes a frontend update.
Getting your API key
Three steps:
Sign up at transcriptapi.com. Takes about 30 seconds.
Go to the API Keys section of your dashboard
Copy the key
Now store it as an environment variable. This is important. Never hardcode API keys in your source code. One accidental git push and your key is public.
# macOS / Linux
export TRANSCRIPT_API_KEY="your_key_here"
# Windows PowerShell
$env:TRANSCRIPT_API_KEY = "your_key_here"
Your key is set. You've got 100 free credits. Let's start extracting.

Method 1: extract a single transcript with cURL
This is the fastest way to test that everything works. If you can run a cURL command, you can get a transcript.
The basic cURL command
curl "https://transcriptapi.com/api/v2/youtube/transcript?video_url=dQw4w9WgXcQ&send_metadata=true" \
-H "Authorization: Bearer $TRANSCRIPT_API_KEY"
Let me break down what's happening here:
video_url-- you can pass a full YouTube URL, a shortyoutu.be/link, or just the 11-character video ID. The API handles all three formats.send_metadata=true-- includes the video title, author name, and thumbnail URL in the response. No extra cost.Authorization: Bearer-- your API key goes in the header, not the URL. This is standard REST API practice.
The response comes back as JSON:
{
"video_id": "dQw4w9WgXcQ",
"language": "en",
"transcript": [
{ "text": "Never gonna give you up", "start": 0.0, "duration": 2.5 },
{ "text": "Never gonna let you down", "start": 2.5, "duration": 2.3 },
{ "text": "Never gonna run around and desert you", "start": 4.8, "duration": 3.1 }
],
"title": "Rick Astley - Never Gonna Give You Up",
"author_name": "Rick Astley"
}
Each segment has three fields: the spoken text, the start time in seconds, and how long the segment lasts. Simple, predictable, and always the same shape.
Getting plain text instead of JSON
If you just want the raw transcript without timestamps or JSON structure:
curl "https://transcriptapi.com/api/v2/youtube/transcript?video_url=dQw4w9WgXcQ&format=text" \
-H "Authorization: Bearer $TRANSCRIPT_API_KEY"
Set format=text and you get the full transcript as a single block of plain text. This is great for piping into other command-line tools or for quick readability checks.
You can combine this with other Unix tools:
# Get transcript and count words
curl -s "https://transcriptapi.com/api/v2/youtube/transcript?video_url=dQw4w9WgXcQ&format=text" \
-H "Authorization: Bearer $TRANSCRIPT_API_KEY" | wc -w
# Save transcript directly to a file
curl -s "https://transcriptapi.com/api/v2/youtube/transcript?video_url=dQw4w9WgXcQ&format=text" \
-H "Authorization: Bearer $TRANSCRIPT_API_KEY" > transcript.txt
Requesting a specific language
Some videos have transcripts in multiple languages. Add the language parameter to request a specific one:
curl "https://transcriptapi.com/api/v2/youtube/transcript?video_url=VIDEO_ID&lang=es" \
-H "Authorization: Bearer $TRANSCRIPT_API_KEY"
If the requested language isn't available, the API falls back to the default caption track. You'll see the actual language returned in the language field of the response, so you always know what you got back. No guessing.
How many videos do you need to process? If it's more than a handful, let's move to a proper programming language.

Method 2: extract transcripts with Python
Python is the most popular language for data processing and AI workflows. Here's how to use it with TranscriptAPI.
Basic Python script using requests
Here's a complete, working script with proper error handling:
import requests
import os
import json
API_KEY = os.environ["TRANSCRIPT_API_KEY"]
BASE_URL = "https://transcriptapi.com/api/v2"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def get_transcript(video_url):
"""Extract transcript from a single YouTube video."""
params = {
"video_url": video_url,
"send_metadata": "true"
}
response = requests.get(
f"{BASE_URL}/youtube/transcript",
params=params,
headers=HEADERS
)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
raise Exception("Invalid API key. Check your TRANSCRIPT_API_KEY.")
elif response.status_code == 402:
raise Exception("Out of credits. Top up at transcriptapi.com/billing")
elif response.status_code == 404:
print(f"Video not found or private: {video_url}")
return None
elif response.status_code == 422:
print(f"No captions available: {video_url}")
return None
elif response.status_code == 429:
print("Rate limited. Wait and retry.")
return None
else:
print(f"Unexpected error {response.status_code}: {response.text}")
return None
def transcript_to_text(data):
"""Join transcript segments into a single string."""
if not data or "transcript" not in data:
return ""
return " ".join(seg["text"] for seg in data["transcript"])
# Example usage
video = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
result = get_transcript(video)
if result:
full_text = transcript_to_text(result)
print(f"Title: {result.get('title', 'Unknown')}")
print(f"Language: {result.get('language', 'Unknown')}")
print(f"Word count: {len(full_text.split())}")
print(f"Segments: {len(result['transcript'])}")
print(f"\nFirst 500 chars:\n{full_text[:500]}...")
Run this and you'll see the video title, detected language, word count, segment count, and the first 500 characters of the transcript.
Notice the error handling. Each HTTP status code gets its own response:
401 -- your key is wrong or missing. Fix it before retrying.
402 -- you're out of credits. Top up your account.
404 -- the video doesn't exist, is private, or was deleted.
422 -- the video exists but has no captions available.
429 -- you're sending requests too fast. Wait a moment.
Here's the good news: failed requests cost zero credits. Errors don't eat into your balance.
Batch extraction with asyncio
Processing one video at a time is fine for testing. For real workloads with dozens or hundreds of videos, you want concurrency. That means asyncio.
First, install aiohttp:
pip install aiohttp
Then use this async version:
import asyncio
import aiohttp
import os
import json
import time
API_KEY = os.environ["TRANSCRIPT_API_KEY"]
BASE_URL = "https://transcriptapi.com/api/v2"
MAX_CONCURRENT = 10 # Stay comfortably under rate limits
async def get_transcript_async(session, semaphore, video_url):
"""Extract a transcript with concurrency limiting."""
async with semaphore:
params = {"video_url": video_url, "send_metadata": "true"}
headers = {"Authorization": f"Bearer {API_KEY}"}
try:
async with session.get(
f"{BASE_URL}/youtube/transcript",
params=params,
headers=headers
) as response:
if response.status == 200:
data = await response.json()
return {"url": video_url, "success": True, "data": data}
else:
return {
"url": video_url,
"success": False,
"status": response.status,
"detail": await response.text()
}
except Exception as e:
return {"url": video_url, "success": False, "error": str(e)}
async def batch_extract(video_urls):
"""Process multiple videos concurrently."""
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
start_time = time.time()
async with aiohttp.ClientSession() as session:
tasks = [
get_transcript_async(session, semaphore, url)
for url in video_urls
]
results = await asyncio.gather(*tasks)
elapsed = time.time() - start_time
# Summary report
successes = [r for r in results if r["success"]]
failures = [r for r in results if not r["success"]]
print(f"\nBatch complete in {elapsed:.1f} seconds")
print(f" Total: {len(results)}")
print(f" Successes: {len(successes)}")
print(f" Failures: {len(failures)}")
for f in failures:
reason = f.get("status", f.get("error", "unknown"))
print(f" FAILED: {f['url']} ({reason})")
return results
# Load URLs from a text file (one URL per line)
with open("video_urls.txt") as f:
urls = [line.strip() for line in f if line.strip()]
results = asyncio.run(batch_extract(urls))
# Save successful transcripts
with open("transcripts.json", "w") as f:
data = [r["data"] for r in results if r["success"]]
json.dump(data, f, indent=2)
print(f"\nSaved {len(data)} transcripts to transcripts.json")
The semaphore limits concurrent requests to 10 at a time. This keeps you well within rate limits while still being much faster than sequential processing.
Let me put the speed difference in real numbers. For 100 videos:
Sequential: ~10 seconds (100 requests at ~100ms each, including network overhead)
Concurrent (10 at a time): ~1.5 seconds
That's a 7x speedup. For 1,000 videos, sequential takes about 100 seconds. Concurrent processing finishes in about 15 seconds. The difference keeps growing with scale.

Method 3: extract transcripts with JavaScript
Node.js script using fetch
Node.js 18+ has a built-in fetch API. No extra packages needed. Here's a complete script:
const fs = require("fs");
const API_KEY = process.env.TRANSCRIPT_API_KEY;
const BASE_URL = "https://transcriptapi.com/api/v2";
async function getTranscript(videoUrl) {
const params = new URLSearchParams({
video_url: videoUrl,
send_metadata: "true"
});
const response = await fetch(`${BASE_URL}/youtube/transcript?${params}`, {
headers: { "Authorization": `Bearer ${API_KEY}` }
});
if (!response.ok) {
const detail = await response.text();
console.error(`Error ${response.status} for ${videoUrl}: ${detail}`);
return null;
}
return response.json();
}
function formatTranscript(data) {
if (!data || !data.transcript) return "";
return data.transcript
.map(seg => {
const minutes = Math.floor(seg.start / 60);
const seconds = Math.floor(seg.start % 60);
const timestamp = `${minutes}:${seconds.toString().padStart(2, "0")}`;
return `[${timestamp}] ${seg.text}`;
})
.join("\n");
}
async function main() {
const data = await getTranscript("dQw4w9WgXcQ");
if (data) {
console.log(`Title: ${data.title}`);
console.log(`Language: ${data.language}`);
console.log(`Segments: ${data.transcript.length}`);
const words = data.transcript.map(s => s.text).join(" ").split(" ").length;
console.log(`Word count: ${words}`);
// Save formatted transcript to file
const formatted = formatTranscript(data);
fs.writeFileSync("transcript.txt", formatted);
console.log("\nSaved to transcript.txt");
}
}
main();
This script extracts a transcript, formats each line with a human-readable timestamp, and saves it to a file. Each line gets a [M:SS] prefix so you can jump to the exact moment in the video.
Batch processing in JavaScript
For processing multiple videos, use Promise.all with a concurrency limiter:
const fs = require("fs");
const API_KEY = process.env.TRANSCRIPT_API_KEY;
const BASE_URL = "https://transcriptapi.com/api/v2";
const MAX_CONCURRENT = 10;
async function getTranscript(videoUrl) {
const params = new URLSearchParams({
video_url: videoUrl,
send_metadata: "true"
});
try {
const response = await fetch(`${BASE_URL}/youtube/transcript?${params}`, {
headers: { "Authorization": `Bearer ${API_KEY}` }
});
if (response.ok) {
return { url: videoUrl, success: true, data: await response.json() };
}
return { url: videoUrl, success: false, status: response.status };
} catch (err) {
return { url: videoUrl, success: false, error: err.message };
}
}
async function batchExtract(urls) {
const results = [];
// Process in chunks of MAX_CONCURRENT
for (let i = 0; i < urls.length; i += MAX_CONCURRENT) {
const chunk = urls.slice(i, i + MAX_CONCURRENT);
const chunkResults = await Promise.all(chunk.map(getTranscript));
results.push(...chunkResults);
console.log(`Processed ${Math.min(i + MAX_CONCURRENT, urls.length)}/${urls.length}`);
}
return results;
}
async function main() {
const urls = fs.readFileSync("video_urls.txt", "utf-8")
.split("\n")
.filter(line => line.trim());
const results = await batchExtract(urls);
const successes = results.filter(r => r.success);
const failures = results.filter(r => !r.success);
console.log(`\nDone: ${successes.length} succeeded, ${failures.length} failed`);
fs.writeFileSync("transcripts.json", JSON.stringify(
successes.map(r => r.data), null, 2
));
}
main();
Browser-side extraction (frontend integration)
You can call the API from a browser too. Here's a minimal working example:
<!DOCTYPE html>
<html>
<head><title>Transcript Extractor</title></head>
<body>
<h2>YouTube Transcript Extractor</h2>
<input id="url" placeholder="Paste YouTube URL" style="width: 400px; padding: 8px;" />
<button style="padding: 8px 16px;">Get Transcript</button>
<pre id="output" style="max-height: 500px; overflow-y: auto; background: #f5f5f5; padding: 16px;"></pre>
<script>
async function extract() {
const videoUrl = document.getElementById("url").value;
const output = document.getElementById("output");
if (!videoUrl) {
output.textContent = "Please paste a YouTube URL first.";
return;
}
output.textContent = "Loading...";
try {
const response = await fetch(
`https://transcriptapi.com/api/v2/youtube/transcript?video_url=${encodeURIComponent(videoUrl)}&format=text`,
{ headers: { "Authorization": "Bearer YOUR_API_KEY" } }
);
if (response.ok) {
output.textContent = await response.text();
} else {
output.textContent = `Error: ${response.status} - ${await response.text()}`;
}
} catch (err) {
output.textContent = `Network error: ${err.message}`;
}
}
</script>
</body>
</html>
A word of caution here: putting your API key in frontend JavaScript code exposes it to anyone who views your page source. For production applications, proxy the API call through your own backend server. For quick internal tools or local prototypes, it works fine.
Error handling and edge cases
Real-world code has to deal with things going wrong. Here's how to handle the common issues you'll run into.
Common errors and how to handle them
Status CodeMeaningRetryable?What to Do401Invalid or missing API keyNoCheck your key and header format402Out of creditsNoTop up your account at transcriptapi.com/billing404Video not found or privateNoVerify the URL. Skip and log it.408Request timed outYesRetry with exponential backoff422Invalid URL or no captionsNoCheck the video URL format429Rate limitedYesWait for the Retry-After period500Server errorMaybeRetry once. Contact support if it persists.503Service unavailableYesRetry after 1-5 seconds
The most important thing to understand: only successful 200 responses cost a credit. Every error response is free. So retrying failed requests doesn't waste your budget.
Videos without transcripts
Not every YouTube video has a transcript. Here's what typically lacks captions:
Music videos with no spoken words
Live streams (especially older ones before YouTube added auto-captions to live)
Very short videos under 15-20 seconds
Some older videos uploaded before YouTube's auto-captioning system was widespread
Videos where the creator explicitly disabled captions
Your code should handle these gracefully:
result = get_transcript(video_url)
if result is None:
failed_videos.append(video_url)
continue # Move on to the next video
Across a random sample of YouTube URLs, expect a 90-95% success rate. For a curated list of spoken-word content (tutorials, lectures, interviews), expect 98%+ success.
Retry logic with exponential backoff
For transient errors, retry with increasing wait times:
import time
import random
def get_transcript_with_retry(video_url, max_retries=3):
retryable_codes = {408, 429, 500, 502, 503}
for attempt in range(max_retries + 1):
response = requests.get(
f"{BASE_URL}/youtube/transcript",
params={"video_url": video_url},
headers=HEADERS
)
if response.status_code == 200:
return response.json()
if response.status_code not in retryable_codes:
return None # Permanent failure, don't retry
if attempt < max_retries:
wait_time = (2 ** attempt) + random.uniform(0, 1)
print(f"Retry {attempt + 1}/{max_retries} in {wait_time:.1f}s")
time.sleep(wait_time)
return None # Exhausted retries
The random jitter (random.uniform(0, 1)) prevents the "thundering herd" problem. If you're running multiple workers and they all hit a rate limit at the same time, they'll all retry at the same time too. The jitter spreads the retries out so they don't pile up.

Saving transcripts in different formats
Different use cases need different output formats.
Plain text (for LLMs and search indexes)
full_text = " ".join(seg["text"] for seg in data["transcript"])
with open("transcript.txt", "w") as f:
f.write(full_text)
Timestamped text (for reference and navigation)
lines = []
for seg in data["transcript"]:
minutes = int(seg["start"] // 60)
seconds = int(seg["start"] % 60)
lines.append(f"[{minutes}:{seconds:02d}] {seg['text']}")
with open("transcript_timestamped.txt", "w") as f:
f.write("\n".join(lines))
SRT subtitle format (for video editing)
def to_srt(transcript):
def fmt(seconds):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
entries = []
for i, seg in enumerate(transcript, 1):
start = fmt(seg["start"])
end = fmt(seg["start"] + seg["duration"])
entries.append(f"{i}\n{start} --> {end}\n{seg['text']}\n")
return "\n".join(entries)
with open("subtitles.srt", "w") as f:
f.write(to_srt(data["transcript"]))
SRT is the most widely supported subtitle format. Video editors, media players, and accessibility tools all read it. If you're building anything related to subtitles or video editing, this conversion is essential.
Frequently asked questions
Is it legal to extract YouTube transcripts via API?
Extracting publicly available caption data for legitimate purposes like accessibility, research, and content analysis is generally accepted practice. TranscriptAPI operates as a standard data access service. That said, always respect copyright when using transcript content. Republishing someone's complete transcript as your own content without attribution is a different matter.
How accurate are auto-generated transcripts?
YouTube's auto-generated captions are roughly 95% accurate for clear English speech. Accuracy drops with heavy accents, background noise, technical jargon, and multiple speakers talking at once. Non-English languages vary. For accuracy-sensitive applications, you can post-process transcripts with an LLM to clean up obvious errors. That's a common pattern in production pipelines.
Can I extract transcripts from private or age-restricted videos?
Private videos require authentication that public APIs can't provide. Unlisted videos work fine as long as you have the URL. Age-restricted videos are hit or miss depending on the restriction type. If the API returns a 404 for an age-restricted video, there's unfortunately no workaround.
What's the maximum video length the API supports?
There's no hard limit on video length. The API has successfully processed transcripts from videos that are several hours long. The response time may increase slightly for very long videos (3+ hours), but you'll still get a result in under a second in most cases.
Where to go from here
Extracting YouTube transcripts programmatically comes down to one HTTP request. Send a video URL, get structured text back in under 50ms. Works from any language, any platform, any environment.
Pick the method that fits your stack:
cURL for quick tests, shell scripts, and one-off extractions
Python for data pipelines, batch processing, and AI workflows
JavaScript for web applications, Node.js services, and browser-based tools
Get started with 100 free credits at transcriptapi.com. You'll extract your first transcript in under a minute.
For Python-specific patterns including async batch processing, check out the Python quick-start tutorial. For processing entire channels or playlists, see the bulk extraction guide. And for the full API reference with all seven endpoints, read the complete developer guide.
What's the first thing you'll build with transcript data?
Meta description: Step-by-step guide to extracting YouTube transcripts programmatically using REST APIs. Includes Python, JavaScript, and cURL examples with error handling patterns.
One of the most powerful applications: building a RAG pipeline with YouTube transcripts to create searchable knowledge bases.
See it in action: our video summarizer tutorial builds a complete summarization API in 30 minutes.
Frequently Asked Questions
- What's the fastest way to test YouTube transcript extraction before writing any code?
- A single cURL command. You call the TranscriptAPI transcript endpoint with the video URL or ID as a query parameter and your API key in an Authorization Bearer header, and the response comes back as JSON: a transcript array where each item has the text, its start time, and its duration. Adding a send-metadata flag also returns the video title, author, and thumbnail at no extra cost — no installation, just one terminal command.
- Do I need to pass the full YouTube URL, or can I just pass the video ID?
- You can pass any of three formats — the full watch URL, a short youtu.be link, or just the 11-character video ID — and TranscriptAPI normalizes all of them automatically. You don't have to write any URL-parsing logic; send whichever form you already have.
- How do I process a batch of YouTube URLs in Python without hitting rate limits?
- Keep concurrency within the plan's documented rate limit, handle errors per video so one failure doesn't abort the whole batch, and write transcripts to disk incrementally instead of holding everything in memory. Read the API key from an environment variable rather than hardcoding it — one accidental commit and the key is public. With those patterns a Python script can work through hundreds of URLs unattended, retrying only the ones that failed.



