API reference
Download YouTube videos
from your own code.
Two endpoints. Resolve a URL, stream the bytes, mux on your side. Plus subscription $1/month — no per-call billing, no per-GB billing, no surprises.
Overview#
The VidPickr API resolves a YouTube URL into downloadable video and audio tracks and proxies the raw bytes through our infrastructure. Two endpoints, no SDK required, no audio or video state stored on our side. You bring an API key, we hand back the streams.
Use cases that fit cleanly:
- Batch-download YouTube content for offline editing pipelines
- Build a download button into your own creator tool
- Archive a channel or playlist programmatically
- Pull audio tracks to feed local Whisper / transcription pipelines
- Power a hobby script that doesn't need a CLI dependency on yt-dlp
Calls live under https://api.vidpickr.com/v1. Authentication is by X-API-Key header — keys are minted in your account dashboard and tied to a Plus subscription.
Hello, VidPickr
curl -H "X-API-Key: $VIDPICKR_API_KEY" \
"https://api.vidpickr.com/v1/info?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ"Quickstart#
Three steps to your first download. Total time, including signing up, is under two minutes.
- 1
Upgrade to Plus
API access is a Plus-tier feature ($1/mo). Subscribe here if you haven't already.
- 2
Mint an API key
From your account dashboard, click Create key. Copy the secret — it's shown once.
- 3
Make your first call
Set
VIDPICKR_API_KEYin your environment and run the snippet on the right. You'll get a fully muxed MP4 in your working directory.
Resolve + stream + mux
# 1. Set your key
export VIDPICKR_API_KEY="vpk_live_..."
# 2. Resolve the URL — capture the tokens we'll need next
INFO=$(curl -s -H "X-API-Key: $VIDPICKR_API_KEY" \
"https://api.vidpickr.com/v1/info?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ")
V_TOKEN=$(echo "$INFO" | jq -r '.resolutions[] | select(.height==1080) | .video_only[0].download_token')
A_TOKEN=$(echo "$INFO" | jq -r '.audio_only | sort_by(-.bitrate) | .[0].download_token')
# 3. Stream both tracks in parallel, then mux with ffmpeg
curl -s -H "X-API-Key: $VIDPICKR_API_KEY" -o v.mp4 \
"https://api.vidpickr.com/v1/stream?token=$V_TOKEN" &
curl -s -H "X-API-Key: $VIDPICKR_API_KEY" -o a.m4a \
"https://api.vidpickr.com/v1/stream?token=$A_TOKEN" &
wait
ffmpeg -y -i v.mp4 -i a.m4a -c copy out.mp4 && rm v.mp4 a.m4a
echo "wrote out.mp4"Authentication#
Every /v1/* request carries an X-API-Key header. The value is the secret shown at key creation time, prefixed with vpk_live_.
We store only the sha256 hash of the secret — the plaintext key is unrecoverable after creation. If you lose a key, revoke it and mint a new one; lost keys cannot be retrieved.
Treat keys like passwords. Never commit them to git, never send them in client-side JavaScript, never log them. A leaked key gives anyone full access to your quota.
A request with a missing, malformed, revoked, or wrong-tier key returns 401 with a JSON error body. See the Errors section for the shape.
Authenticated request
curl -H "X-API-Key: vpk_live_..." \
"https://api.vidpickr.com/v1/info?url=..."Rate limits#
Limits are enforced per user (not per key) so rotating keys doesn't bypass them. The Plus tier ceilings are abuse-protection levels — normal use never touches them.
| Endpoint | Per minute | Per hour |
|---|---|---|
| GET /v1/info | 100 | 2 000 |
| GET /v1/stream | 60 | 1 500 |
| GET /v1/split_token | 100 | 2 000 |
| GET /v1/subtitle | 100 | 2 000 |
When a limit is hit, the API returns 429 rate_limited with two response headers:
Retry-After— integer seconds until the next slot frees.X-RateLimit-Remaining— slots left in the current minute window (always present, even on 200 responses).
Robust clients respect Retry-After and back off with jitter. Hammering at the limit boundary just keeps you 429'd.
Backoff on 429
async function call(url) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(url, { headers: { "X-API-Key": KEY } });
if (res.status !== 429) return res;
const retryAfter = parseInt(res.headers.get("Retry-After") || "5", 10);
const jitter = Math.random() * 500;
await new Promise((r) => setTimeout(r, retryAfter * 1000 + jitter));
}
throw new Error("rate-limited too many times");
}Errors#
Every error response is JSON with a consistent shape so SDKs can parse uniformly. HTTP status reflects the nature; the code string is a stable identifier for branching logic.
| Code | Status | Meaning |
|---|---|---|
| missing_api_key | 401 | No X-API-Key header on the request. |
| invalid_api_key | 401 | Key not recognized or has been revoked. |
| plus_required | 402 | Owner of this key does not have an active Plus subscription. |
| rate_limited | 429 | You hit the per-minute or per-hour ceiling. |
| bad_request | 400 | Required parameter missing or malformed. |
| upstream_failed | 502 | YouTube refused the resolution. Try again or check the URL. |
| lookup_failed | 500 | Our database or resolver had an internal error. |
For client logic, key off code, not the human-readable message. The message text is for humans and may change between versions.
Branch on error.code
# An error response (HTTP 429)
{
"error": {
"code": "rate_limited",
"message": "too many requests, retry in 14s"
}
}Versioning#
The version is in the URL path (/v1/). Breaking changes ship as a new version; we'll keep the previous version online for at least 12 months and announce sunset dates in the changelog with email notification.
Non-breaking additions — new fields on responses, new optional query parameters, additional format options — ship inside v1 without notice. Code defensively: tolerate unknown fields, don't assume a fixed set of resolutions.
Version is in the path
# v1 (current)
curl "https://api.vidpickr.com/v1/info?url=..."
# Future versions will live alongside:
# curl "https://api.vidpickr.com/api/v2/info?url=..."Resolve URL#
/v1/infoResolves a YouTube URL into the full list of available video and audio tracks. Response includes signed download_token values you pass to /v1/stream; tokens expire after roughly 10 minutes.
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | yes | A valid YouTube watch URL or shortlink (youtu.be). |
Response fields
| Field | Type | Description |
|---|---|---|
| title | string | Video title from the YouTube metadata. |
| duration_sec | number | Duration in seconds. |
| platform | string | Always "youtube" for now. |
| thumbnail | string | URL of the highest-res thumbnail. |
| resolutions | array | One entry per available height, with codec variants under video_only. |
| audio_only | array | Standalone audio tracks for MP3/M4A pipelines. |
| subtitles | array | Available subtitle tracks (auto-generated and uploader-provided). |
Resolutions are not exhaustive. A 4K or 8K video may not have every codec at every height — match on height and pick the codec you want from video_only.
GET /v1/info — response
$ curl -H "X-API-Key: vpk_live_..." \
"https://api.vidpickr.com/v1/info?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ"
{
"title": "Never Gonna Give You Up",
"duration_sec": 213,
"platform": "youtube",
"thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp",
"resolutions": [
{
"height": 1080,
"video_only": [
{
"ext": "mp4",
"vcodec": "av01.0.08M.08",
"size_mb": 28.4,
"download_token": "v1.eyJWIjoidjEiLCJUIjoiYXYwMSIsIkUiOjE3..."
},
{
"ext": "webm",
"vcodec": "vp09.00.50.08",
"size_mb": 35.1,
"download_token": "v1.eyJWIjoidjEiLCJUIjoidnA5IiwiRSI6MTc..."
}
]
},
{ "height": 720, "video_only": [ /* ... */ ] },
{ "height": 480, "video_only": [ /* ... */ ] }
],
"audio_only": [
{
"ext": "m4a",
"acodec": "mp4a.40.2",
"bitrate": 128,
"size_mb": 3.2,
"download_token": "a1.eyJWIjoiYTEiLCJUIjoiYWFjIiwiRSI6MTc..."
},
{
"ext": "webm",
"acodec": "opus",
"bitrate": 160,
"size_mb": 4.1,
"download_token": "a1.eyJWIjoiYTEiLCJUIjoib3B1cyIsIkUiOjE3..."
}
],
"subtitles": [
{ "language": "en", "kind": "manual", "download_token": "s1..." },
{ "language": "tr", "kind": "auto", "download_token": "s1..." }
]
}Stream track#
/v1/streamStreams a single video or audio track. The response body is the raw bytes — set a streaming reader on your side and pipe to disk. Content-Length is set when YouTube reports it; otherwise the connection closes when the stream is complete.
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | yes | A download_token from a recent /v1/info response. HMAC-signed and time-limited. |
Response headers
| Header | Description |
|---|---|
| Content-Type | video/mp4, audio/mp4, etc. — matches the source codec. |
| Content-Length | Total bytes, when YouTube reports it (most cases). |
| Content-Disposition | attachment; filename="..." — safe default for browser saves. |
| X-RateLimit-Remaining | Stream-quota slots left in the current minute. |
Tokens are single-use-window, not single-use. You can retry a failed stream with the same token within its lifetime, but after ~10 minutes you'll need to re-resolve.
GET /v1/stream — pipe to disk
curl -H "X-API-Key: vpk_live_..." \
-o video.mp4 \
"https://api.vidpickr.com/v1/stream?token=v1.eyJWIjoidjEi..."Split merge token#
/v1/split_tokenVideo formats in /v1/info return tokens that bundle both the video and audio source URLs into a single signed string (the response field marks them with endpoint: "merge"). To stream them, exchange that merge token here for a separate video-only token and audio-only token, then call /v1/stream twice in parallel.
The Node SDK does this transparently. You only need /v1/split_token when you're calling the raw API by hand (curl, untyped HTTP client, custom pipeline).
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | yes | A merge download_token from /v1/info — i.e. a video resolution token where endpoint is "merge". |
Response fields
| Field | Type | Description |
|---|---|---|
| video_token | string | Pass to /v1/stream to fetch the video-only track. |
| audio_token | string | Pass to /v1/stream to fetch the audio-only track. Mux the two with ffmpeg -c copy, or with the SDK. |
GET /v1/split_token — exchange + stream both halves
# Resolve the URL and grab the 1080p merge token
INFO=$(curl -s -H "X-API-Key: $VIDPICKR_API_KEY" \
"https://api.vidpickr.com/v1/info?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ")
MERGE=$(echo "$INFO" | jq -r '.resolutions[] | select(.height==1080) | .download_token')
# Split into video-only + audio-only tokens
SPLIT=$(curl -s -H "X-API-Key: $VIDPICKR_API_KEY" \
"https://api.vidpickr.com/v1/split_token?token=$MERGE")
V_TOK=$(echo "$SPLIT" | jq -r '.video_token')
A_TOK=$(echo "$SPLIT" | jq -r '.audio_token')
# Stream both, then mux with ffmpeg
curl -s -H "X-API-Key: $VIDPICKR_API_KEY" -o v.mp4 \
"https://api.vidpickr.com/v1/stream?token=$V_TOK" &
curl -s -H "X-API-Key: $VIDPICKR_API_KEY" -o a.m4a \
"https://api.vidpickr.com/v1/stream?token=$A_TOK" &
wait
ffmpeg -y -i v.mp4 -i a.m4a -c copy out.mp4Download subtitles#
/v1/subtitleEach subtitle track in /v1/info's subtitles[] array carries its own download_token. This endpoint serves the requested track in the requested format. Default is SRT; pass ?format=vtt or ?format=txt for the other formats.
Subtitle tokens are not stream tokens. Calling /v1/stream with a subtitle token returns token has no source because the token carries only a timedtext URL, not a media source.
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | yes | A subtitle download_token from /v1/info subtitles[]. |
| format | string | no | srt (default), vtt, or txt. |
Response
The track in the chosen format, with Content-Disposition: attachment so browser-based callers save it directly. Body is plain text.
GET /v1/subtitle — captions in SRT / VTT / TXT
# Resolve, find the language track, download as SRT
INFO=$(curl -s -H "X-API-Key: $VIDPICKR_API_KEY" \
"https://api.vidpickr.com/v1/info?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ")
TOKEN=$(echo "$INFO" | jq -r '.subtitles[] | select(.code=="en" and .is_auto==false) | .download_token')
# Default format is SRT
curl -s -H "X-API-Key: $VIDPICKR_API_KEY" \
-o captions.srt \
"https://api.vidpickr.com/v1/subtitle?token=$TOKEN"
# Or VTT / TXT
curl -s -H "X-API-Key: $VIDPICKR_API_KEY" \
-o captions.vtt \
"https://api.vidpickr.com/v1/subtitle?token=$TOKEN&format=vtt"Muxing video + audio#
For resolutions above 720p, YouTube ships video and audio as separate tracks. After fetching both, merge them into a single MP4 with a stream-copy operation — no re-encoding, takes about a second per minute of video.
Three muxer choices, in order of preference
- Official SDK — bundles a native MP4 muxer transparently. One function call handles resolve + parallel stream + mux + write.
npm install vidpickris available now; Python and Go SDKs are in development. - Language-native MP4 muxer — zero-binary libraries that handle the stream-copy yourself:
mp4-muxer+mp4boxin JS,mp4ffin Go, or a Rust extension shim for Python. Same pipeline the official SDK uses internally. - ffmpeg — the universal fallback. One CLI invocation, works everywhere, but pulls an ~80 MB binary dependency you need to install separately.
Stream-copy mux
# Both files already downloaded via /v1/stream
ffmpeg -y -i video.mp4 -i audio.m4a -c copy out.mp4
# That's it. No re-encoding (the -c copy flag).
# Takes about a second per minute of source video.Parallel downloads#
Fetching the video and audio tracks in parallel cuts wall-clock time roughly in half. Both tracks come from the same upstream, but our proxy multiplexes connections cleanly — the rate limit applies to fetch starts per minute, not concurrent connections.
On Python use concurrent.futures.ThreadPoolExecutor with two workers. On Node use Promise.all([fetchVideo(), fetchAudio()]). On Go fan two goroutines with a sync.WaitGroup or errgroup.Group.
Concurrency cap. A single client should keep concurrent stream fetches under 10. There's no hard limit yet but heavy concurrency from one user gets flagged for review.
Concurrent video + audio fetch
await Promise.all([
pull(video.download_token, "v.mp4"),
pull(audio.download_token, "a.m4a"),
]);Best practices#
Six things that separate a robust client from one that breaks on the first edge case:
- Stream, don't buffer. A 4K video can be a gigabyte. Loading the whole response into memory will OOM on small machines. Pipe directly to disk.
- Resolve, then immediately stream. Tokens are time-limited. Don't batch resolutions days in advance — that list expires.
- Pick by height + codec, not order. Format order is not stable across requests. Iterate
resolutions[]and match on the height + codec you want. - Honor Retry-After. Back off with jitter on 429. Don't hot-loop the API.
- Treat 502 as retryable. Upstream YouTube hiccups happen — one immediate retry usually fixes it. Two consecutive 502s on the same URL is a real failure; surface it.
- Never log full keys. If you must log, mask: keep the
vpk_live_prefix + last 4 chars.
Robust download — full pattern
import os, time, random, requests
from contextlib import contextmanager
API_KEY = os.environ["VIDPICKR_API_KEY"]
BASE = "https://api.vidpickr.com/v1"
def masked_key(k):
return k[:9] + "..." + k[-4:] # vpk_live_...abcd
def call_with_backoff(method, url, **kw):
"""Retries on 429 with Retry-After + jitter; retries once on 502."""
for attempt in range(5):
r = requests.request(method, url, headers={"X-API-Key": API_KEY}, **kw)
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", "5"))
time.sleep(wait + random.random() * 0.5)
continue
if r.status_code == 502 and attempt == 0:
time.sleep(2)
continue
return r
r.raise_for_status()
return r
def resolve(youtube_url):
return call_with_backoff("GET", f"{BASE}/info",
params={"url": youtube_url}).json()
@contextmanager
def stream_to(path, token):
r = call_with_backoff("GET", f"{BASE}/stream",
params={"token": token}, stream=True)
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 20):
f.write(chunk)
yield pathOfficial SDKs#
Each official SDK wraps the raw API with idiomatic client objects, handles parallel streaming, and bundles a native MP4 muxer — so you don't need ffmpeg installed.
Node.js
@vidpickr/sdkavailablenpm install @vidpickr/sdk. Pure JS muxer via mp4-muxer (no ffmpeg dependency), ESM + CJS exports, Node 18+ and Bun. Source: github.com/vidpickr/sdk-node.
Python
vidpickravailablepip install vidpickr. Subprocess-ffmpeg by default (small wheel); install vidpickr[bundled-ffmpeg] to pull imageio-ffmpeg and skip the system dependency. Python 3.9+. Source: github.com/vidpickr/sdk-python.
Go
github.com/vidpickr/sdk-goavailablego get github.com/vidpickr/sdk-go. Pure-Go MP4 muxer via mp4ff — zero runtime dependencies, single-binary install. CLI at cmd/vidpickr. Source: github.com/vidpickr/sdk-go.
Watch the changelog or follow @vidpickr on X for release announcements.
SDK quickstart
# Three SDKs published — npm @vidpickr/sdk, pip vidpickr,
# go get github.com/vidpickr/sdk-go. The curl + ffmpeg pattern in
# Quickstart still works if you'd rather skip the SDK.Changelog#
Notable additions and bug fixes to the public API. We publish a new entry every time something user-visible changes; subscribe to the full changelog for the broader product history.
Initial release
- GET /v1/info — resolve URLs into format options
- GET /v1/stream — stream proxy for individual tracks
- X-API-Key header authentication
- Per-user rate limits (Plus tier)
- Dashboard for key creation, listing, and revocation