Create Job
curl --request POST \
--url https://api.tornadoapi.io/jobs \
--header 'Content-Type: application/json' \
--header 'x-api-key: <x-api-key>' \
--data '
{
"url": "<string>",
"format": "<string>",
"video_codec": "<string>",
"audio_codec": "<string>",
"audio_bitrate": "<string>",
"video_quality": 123,
"filename": "<string>",
"folder": "<string>",
"webhook_url": "<string>",
"audio_only": true,
"download_subtitles": true,
"download_thumbnail": true,
"quality_preset": "<string>",
"max_resolution": "<string>",
"clip_start": "<string>",
"clip_end": "<string>",
"live_recording": true,
"live_from_start": true,
"max_duration": 123,
"wait_for_video": true,
"enable_progress_webhook": true,
"storage": {},
"storage_provider": "<string>",
"paused": true
}
'import requests
url = "https://api.tornadoapi.io/jobs"
payload = {
"url": "<string>",
"format": "<string>",
"video_codec": "<string>",
"audio_codec": "<string>",
"audio_bitrate": "<string>",
"video_quality": 123,
"filename": "<string>",
"folder": "<string>",
"webhook_url": "<string>",
"audio_only": True,
"download_subtitles": True,
"download_thumbnail": True,
"quality_preset": "<string>",
"max_resolution": "<string>",
"clip_start": "<string>",
"clip_end": "<string>",
"live_recording": True,
"live_from_start": True,
"max_duration": 123,
"wait_for_video": True,
"enable_progress_webhook": True,
"storage": {},
"storage_provider": "<string>",
"paused": True
}
headers = {
"x-api-key": "<x-api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'x-api-key': '<x-api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
url: '<string>',
format: '<string>',
video_codec: '<string>',
audio_codec: '<string>',
audio_bitrate: '<string>',
video_quality: 123,
filename: '<string>',
folder: '<string>',
webhook_url: '<string>',
audio_only: true,
download_subtitles: true,
download_thumbnail: true,
quality_preset: '<string>',
max_resolution: '<string>',
clip_start: '<string>',
clip_end: '<string>',
live_recording: true,
live_from_start: true,
max_duration: 123,
wait_for_video: true,
enable_progress_webhook: true,
storage: {},
storage_provider: '<string>',
paused: true
})
};
fetch('https://api.tornadoapi.io/jobs', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.tornadoapi.io/jobs",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'url' => '<string>',
'format' => '<string>',
'video_codec' => '<string>',
'audio_codec' => '<string>',
'audio_bitrate' => '<string>',
'video_quality' => 123,
'filename' => '<string>',
'folder' => '<string>',
'webhook_url' => '<string>',
'audio_only' => true,
'download_subtitles' => true,
'download_thumbnail' => true,
'quality_preset' => '<string>',
'max_resolution' => '<string>',
'clip_start' => '<string>',
'clip_end' => '<string>',
'live_recording' => true,
'live_from_start' => true,
'max_duration' => 123,
'wait_for_video' => true,
'enable_progress_webhook' => true,
'storage' => [
],
'storage_provider' => '<string>',
'paused' => true
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"x-api-key: <x-api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.tornadoapi.io/jobs"
payload := strings.NewReader("{\n \"url\": \"<string>\",\n \"format\": \"<string>\",\n \"video_codec\": \"<string>\",\n \"audio_codec\": \"<string>\",\n \"audio_bitrate\": \"<string>\",\n \"video_quality\": 123,\n \"filename\": \"<string>\",\n \"folder\": \"<string>\",\n \"webhook_url\": \"<string>\",\n \"audio_only\": true,\n \"download_subtitles\": true,\n \"download_thumbnail\": true,\n \"quality_preset\": \"<string>\",\n \"max_resolution\": \"<string>\",\n \"clip_start\": \"<string>\",\n \"clip_end\": \"<string>\",\n \"live_recording\": true,\n \"live_from_start\": true,\n \"max_duration\": 123,\n \"wait_for_video\": true,\n \"enable_progress_webhook\": true,\n \"storage\": {},\n \"storage_provider\": \"<string>\",\n \"paused\": true\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("x-api-key", "<x-api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.tornadoapi.io/jobs")
.header("x-api-key", "<x-api-key>")
.header("Content-Type", "application/json")
.body("{\n \"url\": \"<string>\",\n \"format\": \"<string>\",\n \"video_codec\": \"<string>\",\n \"audio_codec\": \"<string>\",\n \"audio_bitrate\": \"<string>\",\n \"video_quality\": 123,\n \"filename\": \"<string>\",\n \"folder\": \"<string>\",\n \"webhook_url\": \"<string>\",\n \"audio_only\": true,\n \"download_subtitles\": true,\n \"download_thumbnail\": true,\n \"quality_preset\": \"<string>\",\n \"max_resolution\": \"<string>\",\n \"clip_start\": \"<string>\",\n \"clip_end\": \"<string>\",\n \"live_recording\": true,\n \"live_from_start\": true,\n \"max_duration\": 123,\n \"wait_for_video\": true,\n \"enable_progress_webhook\": true,\n \"storage\": {},\n \"storage_provider\": \"<string>\",\n \"paused\": true\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.tornadoapi.io/jobs")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["x-api-key"] = '<x-api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"url\": \"<string>\",\n \"format\": \"<string>\",\n \"video_codec\": \"<string>\",\n \"audio_codec\": \"<string>\",\n \"audio_bitrate\": \"<string>\",\n \"video_quality\": 123,\n \"filename\": \"<string>\",\n \"folder\": \"<string>\",\n \"webhook_url\": \"<string>\",\n \"audio_only\": true,\n \"download_subtitles\": true,\n \"download_thumbnail\": true,\n \"quality_preset\": \"<string>\",\n \"max_resolution\": \"<string>\",\n \"clip_start\": \"<string>\",\n \"clip_end\": \"<string>\",\n \"live_recording\": true,\n \"live_from_start\": true,\n \"max_duration\": 123,\n \"wait_for_video\": true,\n \"enable_progress_webhook\": true,\n \"storage\": {},\n \"storage_provider\": \"<string>\",\n \"paused\": true\n}"
response = http.request(request)
puts response.read_body{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440001",
"total_episodes": 142,
"paused": false,
"episodes": [
{
"job_id": "job-uuid-1",
"url": "https://open.spotify.com/episode/abc",
"title": "Episode 1 - Introduction"
}
],
"episode_jobs": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440002",
"total_videos": 25,
"video_jobs": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
Jobs
Create Job
Create a new download job
POST
/
jobs
Create Job
curl --request POST \
--url https://api.tornadoapi.io/jobs \
--header 'Content-Type: application/json' \
--header 'x-api-key: <x-api-key>' \
--data '
{
"url": "<string>",
"format": "<string>",
"video_codec": "<string>",
"audio_codec": "<string>",
"audio_bitrate": "<string>",
"video_quality": 123,
"filename": "<string>",
"folder": "<string>",
"webhook_url": "<string>",
"audio_only": true,
"download_subtitles": true,
"download_thumbnail": true,
"quality_preset": "<string>",
"max_resolution": "<string>",
"clip_start": "<string>",
"clip_end": "<string>",
"live_recording": true,
"live_from_start": true,
"max_duration": 123,
"wait_for_video": true,
"enable_progress_webhook": true,
"storage": {},
"storage_provider": "<string>",
"paused": true
}
'import requests
url = "https://api.tornadoapi.io/jobs"
payload = {
"url": "<string>",
"format": "<string>",
"video_codec": "<string>",
"audio_codec": "<string>",
"audio_bitrate": "<string>",
"video_quality": 123,
"filename": "<string>",
"folder": "<string>",
"webhook_url": "<string>",
"audio_only": True,
"download_subtitles": True,
"download_thumbnail": True,
"quality_preset": "<string>",
"max_resolution": "<string>",
"clip_start": "<string>",
"clip_end": "<string>",
"live_recording": True,
"live_from_start": True,
"max_duration": 123,
"wait_for_video": True,
"enable_progress_webhook": True,
"storage": {},
"storage_provider": "<string>",
"paused": True
}
headers = {
"x-api-key": "<x-api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'x-api-key': '<x-api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
url: '<string>',
format: '<string>',
video_codec: '<string>',
audio_codec: '<string>',
audio_bitrate: '<string>',
video_quality: 123,
filename: '<string>',
folder: '<string>',
webhook_url: '<string>',
audio_only: true,
download_subtitles: true,
download_thumbnail: true,
quality_preset: '<string>',
max_resolution: '<string>',
clip_start: '<string>',
clip_end: '<string>',
live_recording: true,
live_from_start: true,
max_duration: 123,
wait_for_video: true,
enable_progress_webhook: true,
storage: {},
storage_provider: '<string>',
paused: true
})
};
fetch('https://api.tornadoapi.io/jobs', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.tornadoapi.io/jobs",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'url' => '<string>',
'format' => '<string>',
'video_codec' => '<string>',
'audio_codec' => '<string>',
'audio_bitrate' => '<string>',
'video_quality' => 123,
'filename' => '<string>',
'folder' => '<string>',
'webhook_url' => '<string>',
'audio_only' => true,
'download_subtitles' => true,
'download_thumbnail' => true,
'quality_preset' => '<string>',
'max_resolution' => '<string>',
'clip_start' => '<string>',
'clip_end' => '<string>',
'live_recording' => true,
'live_from_start' => true,
'max_duration' => 123,
'wait_for_video' => true,
'enable_progress_webhook' => true,
'storage' => [
],
'storage_provider' => '<string>',
'paused' => true
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"x-api-key: <x-api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.tornadoapi.io/jobs"
payload := strings.NewReader("{\n \"url\": \"<string>\",\n \"format\": \"<string>\",\n \"video_codec\": \"<string>\",\n \"audio_codec\": \"<string>\",\n \"audio_bitrate\": \"<string>\",\n \"video_quality\": 123,\n \"filename\": \"<string>\",\n \"folder\": \"<string>\",\n \"webhook_url\": \"<string>\",\n \"audio_only\": true,\n \"download_subtitles\": true,\n \"download_thumbnail\": true,\n \"quality_preset\": \"<string>\",\n \"max_resolution\": \"<string>\",\n \"clip_start\": \"<string>\",\n \"clip_end\": \"<string>\",\n \"live_recording\": true,\n \"live_from_start\": true,\n \"max_duration\": 123,\n \"wait_for_video\": true,\n \"enable_progress_webhook\": true,\n \"storage\": {},\n \"storage_provider\": \"<string>\",\n \"paused\": true\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("x-api-key", "<x-api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.tornadoapi.io/jobs")
.header("x-api-key", "<x-api-key>")
.header("Content-Type", "application/json")
.body("{\n \"url\": \"<string>\",\n \"format\": \"<string>\",\n \"video_codec\": \"<string>\",\n \"audio_codec\": \"<string>\",\n \"audio_bitrate\": \"<string>\",\n \"video_quality\": 123,\n \"filename\": \"<string>\",\n \"folder\": \"<string>\",\n \"webhook_url\": \"<string>\",\n \"audio_only\": true,\n \"download_subtitles\": true,\n \"download_thumbnail\": true,\n \"quality_preset\": \"<string>\",\n \"max_resolution\": \"<string>\",\n \"clip_start\": \"<string>\",\n \"clip_end\": \"<string>\",\n \"live_recording\": true,\n \"live_from_start\": true,\n \"max_duration\": 123,\n \"wait_for_video\": true,\n \"enable_progress_webhook\": true,\n \"storage\": {},\n \"storage_provider\": \"<string>\",\n \"paused\": true\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.tornadoapi.io/jobs")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["x-api-key"] = '<x-api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"url\": \"<string>\",\n \"format\": \"<string>\",\n \"video_codec\": \"<string>\",\n \"audio_codec\": \"<string>\",\n \"audio_bitrate\": \"<string>\",\n \"video_quality\": 123,\n \"filename\": \"<string>\",\n \"folder\": \"<string>\",\n \"webhook_url\": \"<string>\",\n \"audio_only\": true,\n \"download_subtitles\": true,\n \"download_thumbnail\": true,\n \"quality_preset\": \"<string>\",\n \"max_resolution\": \"<string>\",\n \"clip_start\": \"<string>\",\n \"clip_end\": \"<string>\",\n \"live_recording\": true,\n \"live_from_start\": true,\n \"max_duration\": 123,\n \"wait_for_video\": true,\n \"enable_progress_webhook\": true,\n \"storage\": {},\n \"storage_provider\": \"<string>\",\n \"paused\": true\n}"
response = http.request(request)
puts response.read_body{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440001",
"total_episodes": 142,
"paused": false,
"episodes": [
{
"job_id": "job-uuid-1",
"url": "https://open.spotify.com/episode/abc",
"title": "Episode 1 - Introduction"
}
],
"episode_jobs": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440002",
"total_videos": 25,
"video_jobs": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
Overview
Creates a new download job. For single videos, returns ajob_id. For Spotify shows and YouTube playlists, automatically creates a batch and returns a batch_id with all episode/video job IDs.
If
url contains a YouTube playlist ID, this endpoint downloads the ENTIRE playlist, not just one video. This applies even if you only meant to download a single video — see Playlist Auto-Detection below before you’re surprised by a batch of thousands of jobs from what looked like a single-video request.Header Parameters
string
required
Your API key for authentication
Request
string
required
The video or show URL to download. If this URL contains a genuine YouTube playlist ID (
list=PL..., UU..., or OL...) or is a Spotify show, the request downloads the WHOLE playlist/show as a batch — see Playlist Auto-Detection.string
Output container format. Video:
mp4, mkv, webm, mov. Audio: m4a, mp3, ogg, opus. Default: mp4 (or m4a when audio_only is true)string
Video codec:
copy (no re-encode), h264, h265, vp9. Default: copystring
Audio codec:
copy (no re-encode), aac, opus, mp3. Default: copy with automatic fallback to aac if incompatiblestring
Audio bitrate when transcoding:
64k, 128k, 192k, 256k, 320k. Default: 192kinteger
Video quality CRF (0-51, lower = better quality). Only used when
video_codec is not copy. Default: 23string
Custom filename (without extension). Max 255 characters. Cannot contain
.., /, \, or null bytes.string
S3 folder prefix for organizing files. Max 200 characters. Cannot contain
.., start with / or \, or be empty/whitespace-only.string
URL to receive completion notification via POST request.
boolean
default:"false"
Extract audio track only. Outputs
m4a (native AAC, no re-encoding) by default. Set format to mp3, ogg, or opus for other audio formats. Supports audio_codec and audio_bitrate for transcoding control.boolean
default:"false"
Download subtitles if available. Returns subtitle URL in job response.
boolean
default:"false"
Download video thumbnail. Returns thumbnail URL in job response.
Known gap (2026-07-08): currently only implemented for Spotify shows. For YouTube — the vast majority of requests — setting this to
true is accepted without error but does not currently produce a thumbnail; no thumbnail_url/thumbnail_key will appear in the job response or completion webhook. Tracked as a fix; not yet shipped. If you need YouTube thumbnails today, fetch them yourself from https://i.ytimg.com/vi/<video_id>/maxresdefault.jpg.string
Quality preset that overrides
video_quality. Options: highest, high, medium, low, lowest.string
Video resolution preference. Options:
best (default), lowest, 2160 (4K), 1440, 1080, 720, 480, 360, 240, 144. Resolution uses the shorter dimension for both horizontal and vertical videos.lowest selects the smallest available video format. A numeric value selects the highest available resolution at or below that value. If every available format exceeds it, the smallest available format is used; the video is not resized. For example, 240 selects 144p when the source offers 144p and 360p, or 360p when the source starts at 360p.For 144 and lowest, separate audio defaults to the lowest available AAC track. Explicit audio codec preferences are respected. A higher bitrate may remain when no lower AAC track is available or when audio is embedded in the selected HLS stream. Check actual_quality in the completed job status for the delivered resolution.string
Start timestamp for video clipping. Format:
HH:MM:SS, MM:SS, or seconds (e.g., 00:01:30 or 90).string
End timestamp for video clipping. Format:
HH:MM:SS, MM:SS, or seconds (e.g., 00:05:00 or 300). Must be greater than clip_start.boolean
default:"false"
Enable live stream recording mode. Auto-detected for live URLs.
boolean
default:"false"
For live streams: record from the beginning (VOD mode) instead of the live point.
integer
Maximum recording duration in seconds. Recommended for live streams as a safety cap. Example:
7200 for 2 hours.boolean
default:"false"
Wait for scheduled/upcoming streams to start before downloading.
boolean
default:"false"
Enable progress webhooks during processing. Sends updates at each stage:
downloading, muxing, uploading.object
Inline storage credentials. Required for marketplace users (RapidAPI, Apify, Zyla). Optional for direct API users (overrides pre-configured storage). Supports 4 providers via the
provider field. See Inline Storage examples below. Mutually exclusive with storage_provider.string
Deliver this job to one of your saved storage destinations, selected by provider:
"s3", "blob" (Azure), "gcs", "oss". Resolved against the destinations configured on your API key first, then on your organization. Returns 400 storage_provider_not_configured if no destination of that provider exists. Mutually exclusive with storage (both present → 400). Omit it to use your default destination. See Choosing a saved destination.boolean
default:"false"
For Spotify show batches only. Creates the batch in paused mode: jobs are not enqueued for processing immediately. Use
PATCH /batch/{id}/jobs to rename episodes, then POST /batch/{id}/start to launch.Single Job Response
string
UUID of the created job
Response
{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
Batch Response (Spotify Shows)
When the URL is a Spotify show, a batch is created automatically:string
UUID of the batch job
integer
Number of episodes in the show
boolean
Whether the batch was created in paused mode
array
List of episode details with job IDs, URLs, titles, descriptions, and release dates
array
List of job IDs for each episode (legacy field)
Response (default mode)
{
"batch_id": "550e8400-e29b-41d4-a716-446655440001",
"total_episodes": 142,
"paused": false,
"episodes": [
{
"job_id": "uuid-1",
"url": "https://open.spotify.com/episode/abc",
"title": "Episode 1 - Introduction",
"description": "In this episode we cover...",
"release_date": "2024-01-15"
},
{
"job_id": "uuid-2",
"url": "https://open.spotify.com/episode/def",
"title": "Episode 2 - Deep Dive",
"description": "A deep dive into...",
"release_date": "2024-01-22"
}
],
"episode_jobs": ["uuid-1", "uuid-2"]
}
Response (paused mode)
{
"batch_id": "550e8400-e29b-41d4-a716-446655440001",
"total_episodes": 142,
"paused": true,
"episodes": [
{
"job_id": "uuid-1",
"url": "https://open.spotify.com/episode/abc",
"title": "Episode 1 - Introduction"
},
{
"job_id": "uuid-2",
"url": "https://open.spotify.com/episode/def",
"title": "Episode 2 - Deep Dive"
}
],
"episode_jobs": ["uuid-1", "uuid-2"]
}
Examples
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"format": "mp4",
"filename": "my-video"
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"webhook_url": "https://myapp.com/webhook"
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"format": "mkv",
"video_codec": "h265",
"audio_codec": "opus",
"audio_bitrate": "256k",
"video_quality": 20
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://open.spotify.com/show/7iQXmUT7XGuZSzAMjoNWlX",
"folder": "huberman-lab-2024"
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"audio_only": true
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"audio_only": true,
"format": "mp3",
"audio_bitrate": "320k"
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"download_subtitles": true,
"download_thumbnail": true
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"quality_preset": "high"
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"max_resolution": "720"
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"clip_start": "00:01:30",
"clip_end": "00:03:00"
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/live/abc123",
"live_recording": true,
"live_from_start": true,
"max_duration": 3600
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"webhook_url": "https://myapp.com/webhook",
"enable_progress_webhook": true
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"storage": {
"provider": "s3",
"endpoint": "https://s3.us-east-1.amazonaws.com",
"bucket": "my-videos",
"region": "us-east-1",
"access_key": "AKIAIOSFODNN7EXAMPLE",
"secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"folder_prefix": "downloads/",
"base_folder": "videos"
}
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"storage": {
"provider": "blob",
"account_name": "mystorageaccount",
"container": "tornado-downloads",
"account_key": "your-storage-account-key-base64==",
"folder_prefix": "downloads/"
}
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"storage": {
"provider": "gcs",
"project_id": "my-gcp-project",
"bucket": "tornado-downloads",
"service_account_json": "{\"type\":\"service_account\",\"project_id\":\"my-gcp-project\",...}",
"folder_prefix": "downloads/"
}
}'
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"storage": {
"provider": "oss",
"endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"bucket": "tornado-downloads",
"access_key_id": "your-oss-access-key-id",
"access_key_secret": "your-oss-access-key-secret",
"folder_prefix": "downloads/"
}
}'
Choosing a saved destination
If you configured several storage destinations (one per provider) in the dashboard, pick the one a job should use withstorage_provider:
{ "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "storage_provider": "gcs" }
- Accepted values:
"s3","blob"(Azure Blob),"gcs","oss"— the provider of a destination you saved. - Resolution order: destinations on the API key, then on the organization. Omit the field to use your default destination (the one marked default, else the most recently saved).
400 storage_provider_not_configured— no saved destination of that provider on the key or the organization.400 storage_and_storage_provider_are_exclusive— you sent bothstorage(inline credentials) andstorage_provider.- If the destination cannot be resolved when the worker uploads (credentials revoked in between), the job is marked Failed — it is never silently delivered elsewhere.
Inline Storage
Thestorage field lets you provide cloud storage credentials directly in the request. This is required for marketplace users and optional for direct API users.
Inline storage credentials take priority over pre-configured storage and are validated before the job is accepted, in three steps — each failure returns
400 with the reason:- Shape — bucket/container name, keys, endpoint (
Invalid storage configuration: …), no network call. - Backend construction — provider-specific requirements (e.g. GCS
project_id). - Live probe — a small test object is uploaded then deleted with your credentials (
Storage credentials validation failed: …). The result is cached for 5 minutes per credential set, so repeated jobs with the samestoragedon’t pay the probe again.
201 Created therefore means the destination is reachable and writable. If, despite that, the backend cannot be initialised at upload time, the job is marked Failed with storage_unreachable — it is never silently delivered to another bucket.Credentials are never logged. For direct API keys they live only in the encrypted job payload until the job completes. For marketplace users (RapidAPI, Apify, Zyla) they are saved so later requests can omit storage — see the marketplace section.Provider Fields
- S3 / S3-Compatible
- Azure Blob
- Google Cloud Storage
- Alibaba OSS
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | Must be "s3" |
endpoint | string | Yes | S3 endpoint URL (e.g., https://s3.us-east-1.amazonaws.com, https://ACCOUNT.r2.cloudflarestorage.com) |
bucket | string | Yes | Bucket name |
region | string | Yes | AWS region (e.g., us-east-1) or auto for R2 |
access_key | string | Yes | Access key ID |
secret_key | string | Yes | Secret access key |
folder_prefix | string | No | Folder prefix (e.g., downloads/2024/) |
base_folder | string | No | Top-level folder (default: videos) |
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | Must be "blob" |
account_name | string | Yes | Azure Storage account name |
container | string | Yes | Container name |
account_key | string | No* | Account key (Base64) |
sas_token | string | No* | SAS token |
folder_prefix | string | No | Folder prefix |
base_folder | string | No | Top-level folder (default: videos) |
account_key or sas_token, not both.| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | Must be "gcs" |
project_id | string | Yes | GCP project ID |
bucket | string | Yes | GCS bucket name |
service_account_json | string | Yes | Full service account JSON as string |
folder_prefix | string | No | Folder prefix |
base_folder | string | No | Top-level folder (default: videos) |
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | Must be "oss" |
endpoint | string | Yes | OSS endpoint (e.g., https://oss-cn-hangzhou.aliyuncs.com) |
bucket | string | Yes | Bucket name |
access_key_id | string | Yes | Access key ID |
access_key_secret | string | Yes | Access key secret |
folder_prefix | string | No | Folder prefix |
base_folder | string | No | Top-level folder (default: videos) |
Success Response
{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440001",
"total_episodes": 142,
"paused": false,
"episodes": [
{
"job_id": "job-uuid-1",
"url": "https://open.spotify.com/episode/abc",
"title": "Episode 1 - Introduction"
}
],
"episode_jobs": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440002",
"total_videos": 25,
"video_jobs": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
Playlist Auto-Detection
POST /jobs inspects url and automatically creates a batch (one job per video) instead of a single job whenever it detects a genuine YouTube playlist. There is no separate “batch” endpoint or flag to opt into this — it happens automatically based on the URL you send.
Which URLs trigger it
url contains… | Example | Behavior |
|---|---|---|
list=PL... | youtube.com/watch?v=xxx&list=PLxxxx or youtube.com/playlist?list=PLxxxx | ✅ Downloads the whole playlist (this is a real, user-created playlist) |
list=UU... | ...&list=UUxxxx | ✅ Downloads the whole playlist (a channel’s uploads) |
list=OL... | ...&list=OLxxxx | ✅ Downloads the whole playlist (an “Online courses”-style auto playlist) |
list=RD... | ...&list=RDxxxx&start_radio=1 | ❌ Downloads only the single video in v= |
list=WL or list=LL | ...&list=WL | ❌ Downloads only the single video in v= |
no list= at all | youtube.com/watch?v=xxx | ❌ Downloads only that one video |
This is the #1 source of “I asked for one video and got thousands of jobs” confusion. YouTube’s own UI automatically appends
&list=RD<video_id>&start_radio=1 to the URL bar any time you open a video from an autoplay queue, the homepage, related videos, or a “Radio”/Mix button — not because you’re viewing a playlist. If you’re integrating by copy-pasting URLs from a browser, always double-check for a list= parameter before sending it here. As of 2026-07-08, list=RD.../WL/LL URLs are correctly treated as single-video requests (this was previously a real bug that caused two production incidents) — but if you’re on an older integration or unsure, strip any list=/start_radio= query parameters from the URL yourself before calling this endpoint to be safe.Size limit
Even a genuine playlist (PL/UU/OL) is capped at 500 videos per request (configurable server-side via MAX_PLAYLIST_BATCH_SIZE). A playlist larger than that is rejected outright with a 413 Payload Too Large — it is never silently truncated. If you need to download a larger playlist, split it into smaller batches or contact support.
Response
string
UUID of the batch job
integer
Number of videos in the playlist
array
List of job IDs for each video
Genuine Playlist (downloads all videos)
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf"
}'
Response
{
"batch_id": "550e8400-e29b-41d4-a716-446655440002",
"total_videos": 25,
"video_jobs": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
Radio/Mix URL (downloads only the one video)
curl -X POST "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=LVCpDa6ymtQ&list=RDLVCpDa6ymtQ&start_radio=1"
}'
Response
{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
413 Payload Too Large (playlist exceeds the cap)
{
"error": "This playlist has 2848 videos, which exceeds the 500-video limit for automatic playlist expansion in a single request.",
"hint": "Split the playlist into smaller batches (e.g. multiple playlist URLs, or the batch API), or contact support@velys.software if you need a one-off exception.",
"playlist_video_count": 2848,
"max_playlist_batch_size": 500
}
Error Responses
{
"error": "Folder name too long (max 200 characters)"
}
{
"error": "Invalid folder name: path traversal not allowed"
}
{
"error": "Folder name cannot be empty or whitespace only"
}
{
"error": "Invalid video URL"
}
{
"error": "Invalid webhook URL"
}
{
"error": "Invalid resolution '4k'. Valid options: [\"best\", \"2160\", \"1440\", \"1080\", \"720\", \"480\", \"360\"]"
}
{
"error": "Invalid clip_start format '90x'. Use HH:MM:SS or seconds"
}
{
"error": "clip_end must be greater than clip_start"
}
{
"error": "No episodes found in this show"
}
{
"error": "Missing x-api-key header"
}
{
"error": "Invalid API Key"
}
{
"error": "Storage quota exceeded",
"limit_gb": "1024.00",
"used_gb": "1024.00",
"message": "This API key has a 1024 GB limit and has used 1024.00 GB"
}
{
"error": "IP address not allowed for this API key"
}
{
"error": "Server is at capacity, please retry later",
"queue_depth": 5000,
"retry_after": 30
}
{
"error": "Service is draining, try another node",
"retry_after": 30
}
{
"error": "Spotify show extraction timed out. The show may have too many episodes."
}
Notes
The
429 Too Many Requests and 503 Service Unavailable responses include a Retry-After header indicating how many seconds to wait before retrying.Codec auto-correction: Incompatible codec/format combinations are automatically corrected to ensure valid output:
webm+h264→ video codec changed tovp9webm+aac→ audio codec changed toopusogg/opus+aac→ audio codec changed toopusmp3+aac→ audio codec changed tomp3- Setting
audio_bitratewithoutaudio_codec→ audio codec defaults toaac
format values are silently replaced with mp4.Was this page helpful?