# Get Batch Status
Source: https://docs.tornadoapi.io/api-reference/batch/get-status
GET /batch/{id}
Get the status of a Spotify show batch download
## Overview
Retrieves the current status and progress of a batch download operation.
## Header Parameters
Your API key for authentication
## Path Parameters
The batch UUID returned from `POST /jobs` when submitting a Spotify show URL
## Response
Batch UUID
Original Spotify show URL
Batch status: `paused`, `processing`, `completed`, or `finished`
S3 folder prefix for episodes
Total number of episodes in the show
Number of successfully completed episodes
Number of failed episodes
Number of skipped episodes (e.g. audio-only Spotify episodes protected by Widevine DRM)
List of individual job UUIDs for each episode
## Example
```bash Request theme={null}
curl -X GET "https://api.tornadoapi.io/batch/550e8400-e29b-41d4-a716-446655440001" \
-H "x-api-key: sk_your_api_key"
```
```json Processing theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"show_url": "https://open.spotify.com/show/7iQXmUT7XGuZSzAMjoNWlX",
"status": "processing",
"folder": "huberman-lab-2024",
"total_episodes": 142,
"completed_episodes": 45,
"failed_episodes": 2,
"episode_jobs": ["uuid-1", "uuid-2", "uuid-3", "..."]
}
```
```json Completed (All Successful) theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"show_url": "https://open.spotify.com/show/7iQXmUT7XGuZSzAMjoNWlX",
"status": "completed",
"folder": "huberman-lab-2024",
"total_episodes": 142,
"completed_episodes": 142,
"failed_episodes": 0,
"episode_jobs": ["uuid-1", "uuid-2", "..."]
}
```
```json Finished (With Failures) theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"show_url": "https://open.spotify.com/show/7iQXmUT7XGuZSzAMjoNWlX",
"status": "finished",
"folder": "huberman-lab-2024",
"total_episodes": 142,
"completed_episodes": 140,
"failed_episodes": 2,
"episode_jobs": ["uuid-1", "uuid-2", "..."]
}
```
## Status Values
| Status | Meaning |
| ------------ | ----------------------------------------------------------------------- |
| `paused` | Batch created with `paused: true`, waiting for `POST /batch/{id}/start` |
| `processing` | Batch is in progress, episodes are being downloaded |
| `completed` | All episodes finished successfully (0 failures) |
| `finished` | All episodes done, but some failed |
## Progress Calculation
```
Progress = (completed_episodes + failed_episodes) / total_episodes * 100
```
A batch is finished when:
```
completed_episodes + failed_episodes >= total_episodes
```
## Polling Example
```python theme={null}
import requests
import time
def wait_for_batch(batch_id, api_key):
while True:
response = requests.get(
f"https://api.tornadoapi.io/batch/{batch_id}",
headers={"x-api-key": api_key}
)
data = response.json()
completed = data["completed_episodes"]
failed = data["failed_episodes"]
total = data["total_episodes"]
print(f"Progress: {completed + failed}/{total} ({failed} failed)")
if data["status"] in ["completed", "finished"]:
return data
time.sleep(10)
# Usage
result = wait_for_batch("batch-uuid", "sk_your_api_key")
print(f"Final status: {result['status']}")
```
## Success Response
```json 200 OK theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"show_url": "https://open.spotify.com/show/7iQXmUT7XGuZSzAMjoNWlX",
"status": "completed",
"folder": "huberman-lab-2024",
"total_episodes": 142,
"completed_episodes": 142,
"failed_episodes": 0,
"skipped_episodes": 0,
"episode_jobs": ["uuid-1", "uuid-2", "uuid-3"]
}
```
## Error Responses
```json 404 Not Found theme={null}
null
```
## Checking Individual Episodes
To see details about failed episodes, query individual job IDs:
```bash theme={null}
curl -X GET "https://api.tornadoapi.io/jobs/{episode_job_id}" \
-H "x-api-key: sk_your_api_key"
```
# Rename Batch Jobs
Source: https://docs.tornadoapi.io/api-reference/batch/rename-jobs
PATCH /batch/{id}/jobs
Rename episode filenames in a paused batch before starting downloads
## Overview
Rename episode filenames in a paused batch. The batch must be in `paused` status (created with `paused: true`).
## Header Parameters
Your API key for authentication
## Path Parameters
The batch UUID returned from `POST /jobs`
## Request Body
List of rename operations
Each rename item contains:
The job UUID to rename (must belong to this batch)
New filename (without extension). Will be sanitized for safe S3/filesystem usage.
## Response
Number of jobs successfully renamed
List of error messages for failed renames
## Example
```bash Request theme={null}
curl -X PATCH "https://api.tornadoapi.io/batch/550e8400-e29b-41d4-a716-446655440001/jobs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"renames": [
{"job_id": "uuid-1", "filename": "01 - Introduction"},
{"job_id": "uuid-2", "filename": "02 - Getting Started"},
{"job_id": "uuid-3", "filename": "03 - Deep Dive"}
]
}'
```
```json 200 OK theme={null}
{
"updated": 3,
"errors": []
}
```
```json 200 OK (Partial Success) theme={null}
{
"updated": 2,
"errors": ["Job xyz does not belong to this batch"]
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "Batch is not paused. Only paused batches can be renamed."
}
```
```json 401 Unauthorized theme={null}
{
"error": "Batch does not belong to this API key"
}
```
```json 404 Not Found theme={null}
{
"error": "Batch not found"
}
```
# Start Batch
Source: https://docs.tornadoapi.io/api-reference/batch/start
POST /batch/{id}/start
Start a paused batch to begin downloading all episodes
## Overview
Starts a paused batch, enqueuing all episode jobs for processing. The batch must be in `paused` status.
## Header Parameters
Your API key for authentication
## Path Parameters
The batch UUID returned from `POST /jobs`
## Response
The batch UUID
Number of jobs successfully enqueued for processing
New batch status (`processing`)
## Example
```bash Request theme={null}
curl -X POST "https://api.tornadoapi.io/batch/550e8400-e29b-41d4-a716-446655440001/start" \
-H "x-api-key: sk_your_api_key"
```
```json 200 OK theme={null}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440001",
"started_jobs": 142,
"status": "processing"
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "Batch is not paused (current status: processing)"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Batch does not belong to this API key"
}
```
```json 404 Not Found theme={null}
{
"error": "Batch not found"
}
```
## Workflow
The typical paused batch workflow is:
1. `POST /jobs` with `paused: true` - Create batch, get episode titles
2. `PATCH /batch/{id}/jobs` - Rename episodes as needed
3. `POST /batch/{id}/start` - Start downloading
4. `GET /batch/{id}` - Poll for progress
# Bulk Create Jobs
Source: https://docs.tornadoapi.io/api-reference/bulk/create
POST /jobs/bulk
Create multiple download jobs at once
## Overview
Create up to 100 download jobs in a single API request. All jobs share the same encoding options but can have individual filenames. The response includes a grouping `batch_id` and individual `job_ids`; follow each job with `GET /jobs/{id}`.
Use this endpoint for high-volume submission: a bulk request counts as **one HTTP request** toward the API's **1,000 requests per second per authenticated client** ceiling. All direct API keys in the same organization share that budget across API instances. If you receive `429` with `error: "rate_limit_exceeded"`, respect `Retry-After` before retrying. Bulk requires a direct API key; it is not available through marketplace authentication. Storage allowances and server capacity checks still apply.
## Header Parameters
Your API key for authentication
## Request
Array of job items (max 100)
### Job Item
Video URL to download
Custom filename for this specific job
### Shared Options
S3 folder prefix for all jobs
Output format for all jobs. **Video**: `mp4`, `mkv`, `webm`, `mov`. **Audio**: `m4a`, `mp3`, `ogg`, `opus`. Default: `mp4` (or `m4a` when `audio_only` is true)
Video codec for all jobs: `copy`, `h264`, `h265`, `vp9`
Audio codec for all jobs: `copy`, `aac`, `opus`, `mp3`
Audio bitrate for all jobs: `64k`, `128k`, `192k`, `256k`, `320k`
Video quality CRF for all jobs (0-51)
Extract audio only for all jobs. Outputs `m4a` by default (no re-encoding). Set `format` to `mp3`, `ogg`, or `opus` for other audio formats.
Download subtitles for all jobs
Download thumbnails for all jobs
Quality preset for all jobs: `highest`, `high`, `medium`, `low`, `lowest`
Video resolution preference for all jobs: `best`, `lowest`, `2160`, `1440`, `1080`, `720`, `480`, `360`, `240`, `144`. A numeric value chooses the best available resolution at or below the value, or the smallest available format if none fits. `lowest` always requests the smallest available video format. See [single-job resolution and audio selection](/api-reference/jobs/create) for details.
Start timestamp for video clipping (all jobs). Format: `HH:MM:SS` or seconds.
End timestamp for video clipping (all jobs). Format: `HH:MM:SS` or seconds.
Enable live stream recording mode for all jobs.
Record from stream beginning (VOD mode) for all live stream jobs.
Maximum recording duration in seconds for all jobs.
Wait for scheduled streams to start for all jobs.
Webhook URL applied to every job of the batch. Same rules and validation as the single-job `webhook_url`; each job sends its own completion webhook.
## Request Example
```json theme={null}
{
"jobs": [
{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"filename": "never-gonna-give-you-up"
},
{
"url": "https://www.youtube.com/watch?v=9bZkp7q19f0",
"filename": "gangnam-style"
},
{
"url": "https://www.youtube.com/watch?v=kJQP7kiw5Fk"
}
],
"folder": "music-videos",
"format": "mp4",
"max_resolution": "1080",
"video_codec": "h264",
"audio_codec": "aac",
"audio_bitrate": "192k"
}
```
Each item in the `jobs` array only requires a `url`. The `filename` is optional - if not provided, the original video title will be used.
All other options (`folder`, `format`, `video_codec`, etc.) are applied to **all jobs** in the batch.
## Response
Batch ID for tracking all jobs
Number of jobs created
List of individual job IDs
## Examples
```bash Bulk Create theme={null}
curl -X POST "https://api.tornadoapi.io/jobs/bulk" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"jobs": [
{"url": "https://youtube.com/watch?v=video1", "filename": "my-first-video"},
{"url": "https://youtube.com/watch?v=video2", "filename": "my-second-video"},
{"url": "https://youtube.com/watch?v=video3"}
],
"folder": "my-downloads",
"format": "mp4"
}'
```
```javascript Node.js theme={null}
const response = await fetch('https://api.tornadoapi.io/jobs/bulk', {
method: 'POST',
headers: {
'x-api-key': 'sk_your_api_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
jobs: [
{url: 'https://youtube.com/watch?v=video1', filename: 'my-first-video'},
{url: 'https://youtube.com/watch?v=video2', filename: 'my-second-video'},
{url: 'https://youtube.com/watch?v=video3'}
],
folder: 'my-downloads',
format: 'mp4'
})
});
const data = await response.json();
console.log(`Created ${data.total_jobs} jobs`);
console.log(`Batch ID: ${data.batch_id}`);
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.tornadoapi.io/jobs/bulk',
headers={'x-api-key': 'sk_your_api_key'},
json={
'jobs': [
{'url': 'https://youtube.com/watch?v=video1', 'filename': 'my-first-video'},
{'url': 'https://youtube.com/watch?v=video2', 'filename': 'my-second-video'},
{'url': 'https://youtube.com/watch?v=video3'}
],
'folder': 'my-downloads',
'format': 'mp4'
}
)
data = response.json()
print(f"Created {data['total_jobs']} jobs")
```
```bash Audio Extraction theme={null}
curl -X POST "https://api.tornadoapi.io/jobs/bulk" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"jobs": [
{"url": "https://youtube.com/watch?v=song1"},
{"url": "https://youtube.com/watch?v=song2"},
{"url": "https://youtube.com/watch?v=song3"}
],
"folder": "music",
"audio_only": true,
"audio_bitrate": "320k"
}'
```
## Success Response
```json 201 Created theme={null}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440001",
"total_jobs": 3,
"job_ids": [
"660f9511-f30c-52e5-b827-557766551111",
"660f9511-f30c-52e5-b827-557766551112",
"660f9511-f30c-52e5-b827-557766551113"
]
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "No jobs provided"
}
```
```json 400 Bad Request theme={null}
{
"error": "Maximum 100 jobs per bulk request"
}
```
```json 400 Bad Request theme={null}
{
"error": "Invalid folder name: path traversal not allowed"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
## Notes
* Maximum 100 jobs per bulk request
* All jobs share the same encoding options and folder
* Each job can have a unique filename
* `webhook_url` (optional) is shared by all jobs of the batch; each job sends its own webhook
* Use `/jobs/{id}` to poll individual job status
* The batch\_id is for reference only - jobs are processed independently
# API Reference
Source: https://docs.tornadoapi.io/api-reference/introduction
Complete reference for the Tornado API
## Base URL
```
https://api.tornadoapi.io
```
## Authentication
The customer endpoints below use the `x-api-key` header:
```bash theme={null}
curl -H "x-api-key: sk_your_api_key" https://api.tornadoapi.io/usage
```
## Endpoints Overview
### Jobs
| Method | Endpoint | Description |
| -------- | ------------------ | ----------------------------------------------- |
| `POST` | `/jobs` | Create a download job |
| `GET` | `/jobs/{id}` | Get job status |
| `GET` | `/jobs` | List all jobs |
| `DELETE` | `/jobs/{id}` | Cancel a queued or processing job |
| `POST` | `/jobs/{id}/retry` | Retry a failed, warning or client-cancelled job |
| `DELETE` | `/jobs/{id}/file` | Delete a job's file from storage |
### Bulk
| Method | Endpoint | Description |
| ------ | ------------ | ----------------------------------------------- |
| `POST` | `/jobs/bulk` | Create multiple download jobs at once (max 100) |
### Metadata
| Method | Endpoint | Description |
| ------ | ----------- | ------------------------------------------------------- |
| `POST` | `/metadata` | Extract video metadata without downloading |
| `POST` | `/is-short` | Detect whether a YouTube URL is a Short, video, or live |
### Batch Operations
| Method | Endpoint | Description |
| ------- | ------------------- | ----------------------------- |
| `GET` | `/batch/{id}` | Get batch status |
| `PATCH` | `/batch/{id}/jobs` | Rename jobs in a paused batch |
| `POST` | `/batch/{id}/start` | Start a paused batch |
### User
| Method | Endpoint | Description |
| -------- | -------------- | --------------------------------------------------------- |
| `GET` | `/usage` | Get usage statistics |
| `POST` | `/user/s3` | Configure S3-compatible storage (AWS S3, R2, MinIO, etc.) |
| `GET` | `/user/s3` | Get the current S3 storage configuration |
| `DELETE` | `/user/s3` | Remove S3 storage configuration |
| `POST` | `/user/blob` | Configure Azure Blob Storage |
| `DELETE` | `/user/blob` | Remove Azure Blob Storage configuration |
| `POST` | `/user/gcs` | Configure Google Cloud Storage |
| `DELETE` | `/user/gcs` | Remove Google Cloud Storage configuration |
| `POST` | `/user/gdrive` | Configure Google Drive delivery |
| `DELETE` | `/user/gdrive` | Remove Google Drive configuration |
| `POST` | `/user/oss` | Configure Alibaba Cloud OSS |
| `DELETE` | `/user/oss` | Remove Alibaba OSS configuration |
| `POST` | `/user/slack` | Configure Slack failure notifications |
| `DELETE` | `/user/slack` | Remove Slack webhook configuration |
| `POST` | `/user/bucket` | Configure S3 storage (legacy, use `/user/s3` instead) |
| `DELETE` | `/user/bucket` | Remove S3 storage (legacy) |
## Response Format
All responses are JSON. Successful responses have a `2xx` status code.
### Success Response
```json theme={null}
{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
### Error Response
```json theme={null}
{
"error": "Invalid API Key"
}
```
## Status Codes
| Code | Description |
| ----- | ----------------------------------------------------------------------------------------------------------------------- |
| `200` | Success |
| `201` | Created (for POST requests) |
| `400` | Bad Request - Invalid parameters |
| `401` | Unauthorized - Invalid or missing API key |
| `403` | Forbidden - Quota exceeded, IP not allowed, or feature not available on your plan |
| `404` | Not Found - Resource doesn't exist |
| `429` | Too Many Requests - Request rate, server capacity or an account quota reached; inspect the error and retry advice |
| `500` | Internal Server Error |
| `502` | Bad Gateway - Transient upstream failure (e.g. `/is-short`) |
| `503` | Service Unavailable - Node draining, at capacity, or a storage backend is unavailable (includes a `Retry-After` header) |
| `504` | Gateway Timeout - Long operation timed out |
## Rate Limits
The API allows **1,000 requests per second per authenticated client**. For direct access, all API keys in the same organization share one budget across API instances. Using another key in that organization, another connection or another IP address does not increase the allowance. A legacy key without an organization has its own budget. Marketplace requests are counted for the authenticated provider account, so tenants remain separate even when their integration uses a shared key.
The API enforces the limit after authentication. A one-second window starts with the first admitted request; rejected requests do not extend it. If you reach the ceiling, the API responds with **429**, `Retry-After: 1`, and a JSON error:
```json theme={null}
{
"error": "rate_limit_exceeded",
"message": "Too many requests for this authenticated client. Retry after 1 second. For multiple downloads with a direct API key, use POST /jobs/bulk with up to 100 jobs per request.",
"retry_after": 1,
"limit_requests_per_second": 1000,
"bulk_endpoint": "/jobs/bulk",
"max_jobs_per_bulk": 100
}
```
For multiple downloads, use [POST /jobs/bulk](/api-reference/bulk/create) with up to **100 jobs in one HTTP request**. Wait for `Retry-After` before retrying a rejected request. Bulk requires a direct API key and shared encoding options.
If the API cannot check the shared request allowance, it returns **503**.
Storage allowances, account quotas and server capacity checks are separate from the HTTP request ceiling. Their errors have their own explanation; grouping requests does not remove those limits. Poll job status every 2–5 seconds as a starting point and back off on throttling.
## OpenAPI Spec
The raw OpenAPI specification is publicly available at:
```
https://api.tornadoapi.io/openapi.json
```
Use it to generate client SDKs or import the API into tools like Postman or Insomnia. The full human-readable documentation lives at [docs.tornadoapi.io](https://docs.tornadoapi.io) (the `/docs` path on the API host redirects there).
# Cancel Job
Source: https://docs.tornadoapi.io/api-reference/jobs/cancel
DELETE /jobs/{id}
Cancel a queued or in-flight job
## Overview
Cancel a job that is still waiting in the queue **or already being processed**.
* **Queued** — the job is dropped immediately and will never run.
* **In flight** — the worker stops at its next checkpoint: within a few seconds during the download, right after the download, right after the mux, and at any point during the upload. Nothing is delivered and nothing is billed.
* **Already delivered** — a file that has already reached your storage is not recalled. If the cancellation lands in the last moments before the job is finalized, the job completes normally and is billed. While the worker is aborting, a poll may briefly show `Cancelled`; the final status is what counts.
Jobs that already have a final status (`Completed`, `Failed`, `Warning`, `Skipped`, `Cancelled`, `CancelledByAdmin`) cannot be cancelled.
This endpoint cancels work; it does not delete the job record or its stored file. A job in `Warning` has already stopped, so cancelling it returns `400`. Use [Delete Job File](/api-reference/jobs/delete-file) to remove a delivered file.
## Header Parameters
Your API key for authentication
## Path Parameters
The job UUID to cancel
## Response
Human-readable outcome
The job ID
`cancelled` — the job was still queued and is gone. `cancelling` — the job was in flight; the worker aborts at its next checkpoint (poll the job to see the final status).
## Examples
```bash Cancel a Job theme={null}
curl -X DELETE "https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000" \
-H "x-api-key: sk_your_api_key"
```
```javascript Node.js theme={null}
const response = await fetch(
'https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000',
{
method: 'DELETE',
headers: {
'x-api-key': 'sk_your_api_key'
}
}
);
const data = await response.json();
console.log(data);
```
```python Python theme={null}
import requests
response = requests.delete(
'https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000',
headers={'x-api-key': 'sk_your_api_key'}
)
print(response.json())
```
## Success Response
```json 200 OK (queued) theme={null}
{
"message": "Job cancelled successfully",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "cancelled"
}
```
```json 200 OK (in flight) theme={null}
{
"message": "Cancellation requested: the worker aborts at its next checkpoint. A file already delivered is not recalled — the job then completes and is billed.",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "cancelling"
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "Job cannot be cancelled (already finished)"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found theme={null}
{
"error": "Job not found"
}
```
```json 500 Internal Server Error theme={null}
{
"error": "Failed to cancel job"
}
```
## Notes
* Cancelled jobs end with status `Cancelled` and the error message "Job cancelled by user"; they are never billed.
* Cancellation can interrupt an in-flight pipeline. If completion won the race, the job is already terminal. An interrupted multipart upload may leave incomplete parts; cleanup depends on the storage provider and lifecycle configuration.
* A job whose file was already delivered when the cancellation arrived completes and is billed — check the final status with `GET /jobs/{id}`.
* Cancellation is atomic on our side: a job reported as `cancelled` will not be picked up by a worker.
# Create Job
Source: https://docs.tornadoapi.io/api-reference/jobs/create
POST /jobs
Create a new download job
## Overview
Creates a new download job. For single videos, returns a `job_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](#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
Your API key for authentication
## Request
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](#playlist-auto-detection).
Output container format. **Video**: `mp4`, `mkv`, `webm`, `mov`. **Audio**: `m4a`, `mp3`, `ogg`, `opus`. Default: `mp4` (or `m4a` when `audio_only` is true)
Video codec: `copy` (no re-encode), `h264`, `h265`, `vp9`. Default: `copy`
Audio codec: `copy` (no re-encode), `aac`, `opus`, `mp3`. Default: `copy` with automatic fallback to `aac` if incompatible
Audio bitrate when transcoding: `64k`, `128k`, `192k`, `256k`, `320k`. Default: `192k`
Video quality CRF (0-51, lower = better quality). Only used when `video_codec` is not `copy`. Default: `23`
Custom filename (without extension). Max 255 characters. Cannot contain `..`, `/`, `\`, or null bytes.
S3 folder prefix for organizing files. Max 200 characters. Cannot contain `..`, start with `/` or `\`, or be empty/whitespace-only.
URL to receive completion notification via POST request.
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.
Download subtitles if available. Returns subtitle URL in job response.
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//maxresdefault.jpg`.
Quality preset that overrides `video_quality`. Options: `highest`, `high`, `medium`, `low`, `lowest`.
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.
Start timestamp for video clipping. Format: `HH:MM:SS`, `MM:SS`, or seconds (e.g., `00:01:30` or `90`).
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`.
1–100 ranges for separate files from a single source download. Each item
requires string `clip_start` and `clip_end` (seconds, `MM:SS` or `HH:MM:SS`).
Mutually exclusive with top-level `clip_start`/`clip_end`. One job delivers
all outputs through `clip_files`; see [multi-clip example](/features/advanced-options#several-clips-from-one-video).
Enable live stream recording mode. Auto-detected for live URLs.
For live streams: record from the beginning (VOD mode) instead of the live point.
Maximum recording duration in seconds. Recommended for live streams as a safety cap. Example: `7200` for 2 hours.
Wait for scheduled/upcoming streams to start before downloading.
Enable progress webhooks during processing. Sends updates at each stage: `downloading`, `muxing`, `uploading`.
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](#inline-storage) below. Mutually exclusive with `storage_provider`.
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](#choosing-a-saved-destination).
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
UUID of the created job
```json Response theme={null}
{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
## Batch Response (Spotify Shows)
When the URL is a Spotify show, a batch is created automatically:
UUID of the batch job
Number of episodes in the show
Whether the batch was created in paused mode
List of episode details with job IDs, URLs, titles, descriptions, and release dates
List of job IDs for each episode (legacy field)
```json Response (default mode) theme={null}
{
"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"]
}
```
```json Response (paused mode) theme={null}
{
"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
```bash Single Video theme={null}
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"
}'
```
```bash With Webhook theme={null}
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"
}'
```
```bash Custom Encoding theme={null}
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
}'
```
```bash Spotify Show (Batch) theme={null}
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"
}'
```
```bash Audio Only (m4a, default) theme={null}
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
}'
```
```bash Audio Only (mp3) theme={null}
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"
}'
```
```bash With Subtitles & Thumbnail theme={null}
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
}'
```
```bash Quality Preset theme={null}
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"
}'
```
```bash Resolution Selection (720p) theme={null}
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"
}'
```
```bash Video Clipping theme={null}
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"
}'
```
```bash Live Stream Recording theme={null}
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
}'
```
```bash With Progress Webhook theme={null}
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
}'
```
```bash Inline Storage (S3) theme={null}
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"
}
}'
```
```bash Inline Storage (Azure Blob) theme={null}
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/"
}
}'
```
```bash Inline Storage (GCS) theme={null}
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/"
}
}'
```
```bash Inline Storage (Alibaba OSS) theme={null}
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 with `storage_provider`:
```json theme={null}
{ "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 both `storage` (inline credentials) and `storage_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
The `storage` 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:
1. **Shape** — bucket/container name, keys, endpoint (`Invalid storage configuration: …`), no network call.
2. **Backend construction** — provider-specific requirements (e.g. GCS `project_id`).
3. **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 same `storage` don't pay the probe again.
A `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
| 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`) |
\*Provide either `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
```json 201 Created (Single Video) theme={null}
{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
```json 201 Created (Spotify Show Batch) theme={null}
{
"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"]
}
```
```json 201 Created (YouTube Playlist Batch) theme={null}
{
"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&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
UUID of the batch job
Number of videos in the playlist
List of job IDs for each video
```bash Genuine Playlist (downloads all videos) theme={null}
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"
}'
```
```json Response theme={null}
{
"batch_id": "550e8400-e29b-41d4-a716-446655440002",
"total_videos": 25,
"video_jobs": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
```
```bash Radio/Mix URL (downloads only the one video) theme={null}
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"
}'
```
```json Response theme={null}
{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
```json 413 Payload Too Large (playlist exceeds the cap) theme={null}
{
"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
```json 400 Bad Request theme={null}
{
"error": "Folder name too long (max 200 characters)"
}
```
```json 400 Bad Request theme={null}
{
"error": "Invalid folder name: path traversal not allowed"
}
```
```json 400 Bad Request theme={null}
{
"error": "Folder name cannot be empty or whitespace only"
}
```
```json 400 Bad Request theme={null}
{
"error": "Invalid video URL"
}
```
```json 400 Bad Request theme={null}
{
"error": "Invalid webhook URL"
}
```
```json 400 Bad Request theme={null}
{
"error": "Invalid resolution '4k'. Valid options: [\"best\", \"2160\", \"1440\", \"1080\", \"720\", \"480\", \"360\"]"
}
```
```json 400 Bad Request theme={null}
{
"error": "Invalid clip_start format '90x'. Use HH:MM:SS or seconds"
}
```
```json 400 Bad Request theme={null}
{
"error": "clip_end must be greater than clip_start"
}
```
```json 400 Bad Request theme={null}
{
"error": "No episodes found in this show"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 403 Forbidden theme={null}
{
"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"
}
```
```json 403 Forbidden theme={null}
{
"error": "IP address not allowed for this API key"
}
```
```json 429 Too Many Requests theme={null}
{
"error": "Server is at capacity, please retry later",
"queue_depth": 5000,
"retry_after": 30
}
```
```json 503 Service Unavailable theme={null}
{
"error": "Service is draining, try another node",
"retry_after": 30
}
```
```json 504 Gateway Timeout theme={null}
{
"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 to `vp9`
* `webm` + `aac` → audio codec changed to `opus`
* `ogg`/`opus` + `aac` → audio codec changed to `opus`
* `mp3` + `aac` → audio codec changed to `mp3`
* Setting `audio_bitrate` without `audio_codec` → audio codec defaults to `aac`
Invalid `format` values are silently replaced with `mp4`.
# Delete Job File
Source: https://docs.tornadoapi.io/api-reference/jobs/delete-file
DELETE /jobs/{id}/file
Delete a job file from S3 storage
## Overview
Permanently delete a job's downloaded file from S3 storage. The job record is kept in the database, but the `s3_key` field is cleared. Use this to free up storage space for completed downloads you no longer need.
## Header Parameters
Your API key for authentication
## Path Parameters
The job UUID
## Response
Success message
The job ID
The S3 key that was deleted
## Examples
```bash Delete a File theme={null}
curl -X DELETE "https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000/file" \
-H "x-api-key: sk_your_api_key"
```
```javascript Node.js theme={null}
const response = await fetch(
'https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000/file',
{
method: 'DELETE',
headers: {
'x-api-key': 'sk_your_api_key'
}
}
);
const data = await response.json();
console.log(`Deleted: ${data.deleted_key}`);
```
```python Python theme={null}
import requests
response = requests.delete(
'https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000/file',
headers={'x-api-key': 'sk_your_api_key'}
)
print(response.json())
```
## Success Response
```json 200 OK theme={null}
{
"message": "File deleted successfully",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"deleted_key": "videos/my-video.mp4"
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "Job has no file to delete"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
```json 404 Not Found theme={null}
{
"error": "Job not found"
}
```
```json 500 Internal Server Error theme={null}
{
"error": "Failed to delete file: AccessDenied"
}
```
## Notes
* This permanently deletes the file from S3 storage
* The job record is kept in the database for reference
* You cannot recover deleted files
* Only works for jobs with status `Completed`
* Jobs without an `s3_key` (failed or pending) cannot have files deleted
For multi-clip jobs, this deletes all objects in `clip_files`. If the original
storage credentials cannot be resolved and matched to the recorded destination,
the API returns 409. A partially failed deletion can be retried.
The retry also succeeds when some of the original objects were already deleted
by the previous attempt. Keep the same storage destination configured until
the deletion finishes; changing the account, bucket or folder causes a 409.
# Get Job Status
Source: https://docs.tornadoapi.io/api-reference/jobs/get-status
GET /jobs/{id}
Get the status of a download job
## Overview
Retrieves the current status of a download job. When completed, includes a signed download URL for the file.
## Header Parameters
Your API key for authentication
## Path Parameters
The job UUID returned from `POST /jobs`
## Response
Job UUID
Original source URL
Job status: `Pending`, `Processing`, `Completed`, `Failed`, `Warning`, `Skipped`, `Cancelled`, or `CancelledByAdmin`
Signed download URL (only when `Completed`). Tornado-managed storage may use `https://files.tornadoapi.io/?verify=...`. Your own S3 bucket retains its own endpoint and S3 signature. Use the returned URL unchanged when downloading, including its query string.
The `files.tornadoapi.io` delivery domain applies only to Tornado-managed storage. It does not replace a customer's bucket endpoint or change their object key. On that domain, `videos/` is part of the object key; there is no bucket-name segment in the path. A storage configuration that cannot be resolved does not fall back to a Tornado download URL.
Presigned URL for subtitles (if available)
Presigned URL for the thumbnail (only when `download_thumbnail` was enabled). Omitted otherwise.
Error message (when `Failed`, `Warning`, or `Skipped`)
Error classification (when `Failed`, `Warning`, or `Skipped`): `error` for technical failures (rate limits, bot detection, connection issues), `warning` for content issues (private video, members-only, geo-blocked, audio-only Spotify episodes)
Current processing step: `Queued`, `Downloading`, `Muxing`, `Uploading`, `Finished`
Video title (only present for completed jobs fetched from database)
Video/episode description from the source platform (only present for completed jobs)
Release or upload date. Spotify: exact publish date. YouTube: upload date (midnight UTC). ISO 8601 format.
S3 folder prefix if provided at job creation
Requested video resolution preference, for example `144`, `240`, or `lowest`.
Delivered video resolution, measured from the downloaded stream and expressed as its shorter dimension, for example `144p`. It can differ from the request when that resolution is unavailable. This field is null for audio-only jobs and can be absent or null before completion or on older jobs.
Batch UUID if this job is part of a batch (Spotify show or YouTube playlist)
Download speed in MB/s (only present for completed jobs)
Upload speed in MB/s (only present for completed jobs)
Metadata extraction duration in milliseconds (only present for completed jobs)
Download stage duration in milliseconds (only present for completed jobs)
Mux (FFmpeg) stage duration in milliseconds (only present for completed jobs)
Upload stage duration in milliseconds (only present for completed jobs)
Total pipeline duration from pop to completion in milliseconds (only present for completed jobs)
YouTube API pre-check duration in milliseconds (only present when pre-check was performed)
Time spent waiting for IO semaphore in milliseconds (only present for completed jobs)
Time spent waiting for CPU semaphore in milliseconds (only present for completed jobs)
Time spent waiting for upload semaphore in milliseconds (only present for completed jobs)
Subtitle download duration in milliseconds (only present for completed jobs with subtitles)
File move duration in milliseconds (only present for completed jobs)
File size in bytes (only present for completed jobs)
Video codec of the source stream (e.g., `"avc1"`, `"vp9"`, `"av01"`). Only present for completed jobs.
Audio codec of the source stream (e.g., `"mp4a"`, `"opus"`). Only present for completed jobs.
Download strategy used: `native`, `ytdlp`, `cascade`. Only present for completed jobs.
Total download attempts across all strategies (only present for completed jobs)
Number of download retries attempted (only present for completed jobs)
Number of upload retries attempted (only present for completed jobs)
Time spent waiting in queue in milliseconds (only present for completed jobs)
Quality requested by the user (only present for completed jobs)
Actual quality of the downloaded video (only present for completed jobs)
Webhook delivery status (only present when webhook\_url was set)
Creation timestamp in milliseconds since epoch
Completion timestamp in milliseconds since epoch (only present for completed jobs)
## Examples
```bash Request theme={null}
curl -X GET "https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000" \
-H "x-api-key: sk_your_api_key"
```
```json Pending theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://youtube.com/watch?v=dQw4w9WgXcQ",
"status": "Pending",
"s3_url": null,
"subtitle_url": null,
"error": null,
"error_type": null,
"step": "Queued"
}
```
```json Processing theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://youtube.com/watch?v=dQw4w9WgXcQ",
"status": "Processing",
"s3_url": null,
"subtitle_url": null,
"error": null,
"error_type": null,
"step": "Downloading"
}
```
```json Completed theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://youtube.com/watch?v=dQw4w9WgXcQ",
"status": "Completed",
"s3_url": "https://cdn.example.com/videos/video.mp4?X-Amz-Algorithm=...",
"subtitle_url": null,
"error": null,
"step": "Finished",
"title": "Rick Astley - Never Gonna Give You Up",
"description": "The official video for Never Gonna Give You Up...",
"release_date": "2009-10-25T00:00:00Z",
"folder": "music-videos",
"download_speed_mbps": 45.2,
"upload_speed_mbps": 120.5,
"extract_duration_ms": 800,
"download_duration_ms": 3200,
"mux_duration_ms": 1500,
"upload_duration_ms": 2100,
"total_duration_ms": 8050,
"file_size": 52428800,
"native_video_codec": "avc1",
"native_audio_codec": "mp4a",
"download_strategy": "native",
"cascade_total_attempts": 1,
"download_retries": 1,
"upload_retries": 0,
"queue_wait_ms": 450,
"requested_quality": "best",
"actual_quality": "1080p",
"webhook_status": "delivered",
"created_at": 1705507200000,
"finished_at": 1705507260000
}
```
```json Failed theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://youtube.com/watch?v=dQw4w9WgXcQ",
"status": "Failed",
"s3_url": null,
"subtitle_url": null,
"error": "Video unavailable",
"error_type": "warning",
"step": "Downloading"
}
```
```json Skipped theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://open.spotify.com/episode/329nSfeZ5vRoJooKsZh9kH",
"status": "Skipped",
"s3_url": null,
"subtitle_url": null,
"error": "Audio-only Spotify episode (DRM protected) - skipped. This episode contains only audio which is Widevine-encrypted and cannot be downloaded.",
"error_type": "warning",
"step": "Downloading"
}
```
## Status Values
| Status | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `Pending` | Job is in queue, waiting to be processed |
| `Processing` | Job is actively being downloaded/processed |
| `Completed` | Job finished successfully |
| `Failed` | Job encountered a technical error |
| `Warning` | Job failed due to a content issue (private video, members-only, geo-blocked, unavailable) |
| `Skipped` | Job was skipped because the content cannot be processed (e.g. audio-only Spotify episodes with Widevine DRM) |
| `Cancelled` | Job cancelled by the client |
| `CancelledByAdmin` | Job cancelled by an operator |
## Processing Steps
| Step | Description |
| ------------- | ------------------------------- |
| `Queued` | Waiting in queue |
| `Downloading` | Downloading video/audio streams |
| `Muxing` | Combining streams with FFmpeg |
| `Uploading` | Uploading to S3 |
| `Finished` | Complete |
## Polling Example
```python theme={null}
import requests
import time
def wait_for_job(job_id, api_key, timeout=600):
start = time.time()
while time.time() - start < timeout:
response = requests.get(
f"https://api.tornadoapi.io/jobs/{job_id}",
headers={"x-api-key": api_key}
)
data = response.json()
print(f"Status: {data['status']} - {data.get('step', 'N/A')}")
if data["status"] == "Completed":
return data["s3_url"]
elif data["status"] == "Failed":
raise Exception(f"Job failed: {data['error']}")
elif data["status"] in ("Skipped", "Cancelled", "CancelledByAdmin"):
print(f"Job skipped: {data['error']}")
return None
time.sleep(3)
raise Exception("Timeout waiting for job")
```
## Success Response
```json 200 OK theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://youtube.com/watch?v=dQw4w9WgXcQ",
"status": "Completed",
"s3_url": "https://cdn.example.com/videos/video.mp4?X-Amz-Algorithm=...",
"subtitle_url": null,
"error": null,
"step": "Finished",
"title": "Rick Astley - Never Gonna Give You Up",
"description": "The official video for Never Gonna Give You Up...",
"release_date": "2009-10-25T00:00:00Z",
"download_speed_mbps": 45.2,
"upload_speed_mbps": 120.5,
"download_duration_ms": 3200,
"mux_duration_ms": 1500,
"upload_duration_ms": 2100,
"total_duration_ms": 8050,
"file_size": 52428800,
"native_video_codec": "avc1",
"native_audio_codec": "mp4a",
"download_strategy": "native",
"download_retries": 1,
"upload_retries": 0,
"queue_wait_ms": 450,
"requested_quality": "best",
"actual_quality": "1080p",
"created_at": 1705507200000,
"finished_at": 1705507260000
}
```
## Error Responses
```json 404 Not Found theme={null}
null
```
The presigned `s3_url` and `subtitle_url` are valid for 24 hours. Download the files before they expire.
Performance and metadata fields (`title`, `description`, `release_date`, `folder`, `batch_id`, `download_speed_mbps`, `upload_speed_mbps`, `extract_duration_ms`, `download_duration_ms`, `mux_duration_ms`, `upload_duration_ms`, `total_duration_ms`, `precheck_duration_ms`, `io_wait_ms`, `cpu_wait_ms`, `upload_wait_ms`, `subtitle_duration_ms`, `file_move_ms`, `file_size`, `native_video_codec`, `native_audio_codec`, `download_strategy`, `cascade_total_attempts`, `download_retries`, `upload_retries`, `queue_wait_ms`, `requested_quality`, `actual_quality`, `webhook_status`, `finished_at`) are only present for completed/failed jobs that have been persisted to the database. Active jobs (Pending/Processing) return only the base fields (`id`, `url`, `status`, `s3_url`, `subtitle_url`, `error`, `error_type`, `step`, `created_at`). Fields with no value are omitted from the response.
Completed jobs also echo back the original request parameters (`format`, `video_codec`, `audio_codec`, `audio_bitrate`, `video_quality`, `filename`, `audio_only`, `download_subtitles`, `download_thumbnail`, `quality_preset`, `max_resolution`, `clip_start`, `clip_end`, `live_recording`, `live_from_start`, `max_duration`, `wait_for_video`, `enable_progress_webhook`). These are omitted when null.
## Multiple clip files
For a job submitted with `clips`, the completed response includes `clip_files`,
an ordered array of `{clip_start, clip_end, s3_key, s3_url, file_size}`.
Each `s3_url` downloads one excerpt and follows the same expiration and storage
rules as the main URL. `s3_url` at the top level refers to the first excerpt;
`file_size` at the top level is the sum of all excerpts. Before delivery,
`clip_files` is absent. See the [request example](/features/advanced-options#several-clips-from-one-video).
# List Jobs
Source: https://docs.tornadoapi.io/api-reference/jobs/list
GET /jobs
List all jobs for the authenticated user
## Overview
Returns a paginated list of jobs with optional status filtering. Jobs are returned in reverse chronological order (newest first).
## Header Parameters
Your API key for authentication
## Query Parameters
Number of jobs to return. Maximum: 100
Number of jobs to skip for pagination
Filter by job status: `pending`, `processing`, `completed`, `failed`, `warning`
## Response
Array of job objects
Total number of jobs (ignoring filters)
Number of jobs requested
Number of jobs skipped
### Job Object
Job UUID
Original video URL
Job status: `Pending`, `Processing`, `Completed`, `Failed`, `Warning`, `Skipped`, `Cancelled`, `CancelledByAdmin`
S3 object key (when completed)
Error message (when failed)
Error classification (when failed): `error` for technical failures (rate limits, bot detection, connection issues), `warning` for content issues (private video, members-only, geo-blocked, unavailable)
Current processing step: `Queued`, `Downloading`, `Muxing`, `Uploading`, `Finished`
Creation timestamp (milliseconds since epoch)
Completion timestamp (milliseconds since epoch)
File size in bytes (when completed)
## Examples
```bash List All Jobs theme={null}
curl -X GET "https://api.tornadoapi.io/jobs" \
-H "x-api-key: sk_your_api_key"
```
```bash With Pagination theme={null}
curl -X GET "https://api.tornadoapi.io/jobs?limit=50&offset=100" \
-H "x-api-key: sk_your_api_key"
```
```bash Filter by Status theme={null}
curl -X GET "https://api.tornadoapi.io/jobs?status=completed" \
-H "x-api-key: sk_your_api_key"
```
```bash Failed Jobs Only theme={null}
curl -X GET "https://api.tornadoapi.io/jobs?status=failed&limit=10" \
-H "x-api-key: sk_your_api_key"
```
## Success Response
```json 200 OK theme={null}
{
"jobs": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"status": "Completed",
"s3_key": "videos/my-video.mp4",
"error": null,
"error_type": null,
"step": "Finished",
"created_at": 1705507200000,
"finished_at": 1705507260000,
"file_size": 52428800
},
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"url": "https://www.youtube.com/watch?v=abc123",
"status": "Processing",
"s3_key": null,
"error": null,
"error_type": null,
"step": "Downloading",
"created_at": 1705507100000,
"finished_at": null,
"file_size": null
}
],
"total": 142,
"limit": 20,
"offset": 0
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 500 Internal Server Error theme={null}
{
"error": "Database query failed"
}
```
# Retry Job
Source: https://docs.tornadoapi.io/api-reference/jobs/retry
POST /jobs/{id}/retry
Retry a failed, warning or client-cancelled job with the same parameters
## Overview
Re-queue a failed or warning job with the same parameters. Creates a new job with a new ID while preserving the original job's configuration. Jobs with status `Failed` or `Warning` can be retried.
## Header Parameters
Your API key for authentication
## Path Parameters
The failed job UUID to retry
## Response
UUID of the new retry job
UUID of the original failed job
Success message
## Examples
```bash Retry a Failed Job theme={null}
curl -X POST "https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000/retry" \
-H "x-api-key: sk_your_api_key"
```
```javascript Node.js theme={null}
const response = await fetch(
'https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000/retry',
{
method: 'POST',
headers: {
'x-api-key': 'sk_your_api_key'
}
}
);
const data = await response.json();
console.log(`New job ID: ${data.job_id}`);
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.tornadoapi.io/jobs/550e8400-e29b-41d4-a716-446655440000/retry',
headers={'x-api-key': 'sk_your_api_key'}
)
data = response.json()
print(f"New job ID: {data['job_id']}")
```
## Success Response
```json 201 Created theme={null}
{
"job_id": "660f9511-f30c-52e5-b827-557766551111",
"original_job_id": "550e8400-e29b-41d4-a716-446655440000",
"message": "Job re-queued successfully"
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "Only failed, warning or cancelled jobs can be retried"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found theme={null}
{
"error": "Job not found"
}
```
```json 500 Internal Server Error theme={null}
{
"error": "Failed to queue retry job"
}
```
## Notes
* Jobs with status `Failed` or `Warning` can be retried
* A new job is created with a new UUID
* The original failed job remains unchanged (for audit purposes)
* All original parameters are preserved (URL, format, codecs, webhook, etc.)
* The new job uses your current S3 configuration (in case you updated it)
* Usage/billing is counted for the new job
# Get Metadata
Source: https://docs.tornadoapi.io/api-reference/metadata/get
POST /metadata
Extract video metadata without downloading
## Overview
Extract metadata (title, duration, thumbnail, etc.) from a video URL without actually downloading the video. Useful for preview, validation, or getting video information before creating a download job.
## Header Parameters
Your API key for authentication
## Request
The video URL to extract metadata from
## Response
Video title
Video duration in seconds
Video width in pixels
Video height in pixels
Platform name (e.g., "YouTube", "Vimeo")
Channel or uploader name
URL to the video thumbnail
Video description
Number of views
Number of likes
Upload date in YYYYMMDD format
Approximate file size in bytes
## Examples
```bash Get Metadata theme={null}
curl -X POST "https://api.tornadoapi.io/metadata" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
}'
```
```javascript Node.js theme={null}
const response = await fetch('https://api.tornadoapi.io/metadata', {
method: 'POST',
headers: {
'x-api-key': 'sk_your_api_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
})
});
const metadata = await response.json();
console.log(`Title: ${metadata.title}`);
console.log(`Duration: ${metadata.duration}s`);
console.log(`Resolution: ${metadata.width}x${metadata.height}`);
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.tornadoapi.io/metadata',
headers={'x-api-key': 'sk_your_api_key'},
json={'url': 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'}
)
metadata = response.json()
print(f"Title: {metadata['title']}")
print(f"Duration: {metadata['duration']}s")
```
## Success Response
```json 200 OK theme={null}
{
"title": "Rick Astley - Never Gonna Give You Up (Official Video)",
"duration": 213.0,
"width": 1920,
"height": 1080,
"extractor": "YouTube",
"uploader": "Rick Astley",
"thumbnail": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
"description": "The official video for Never Gonna Give You Up...",
"view_count": 1500000000,
"like_count": 15000000,
"upload_date": "20091025",
"filesize_approx": 52428800
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "Failed to extract metadata",
"details": "Video unavailable"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 500 Internal Server Error theme={null}
{
"error": "Failed to extract metadata"
}
```
## Use Cases
### Preview Before Download
Get video information before creating a download job to show users what they're about to download.
### Validate URLs
Check if a URL is valid and supported before adding it to a batch.
### Get Thumbnail
Extract the thumbnail URL to display in your UI without downloading the full video.
### Estimate Storage
Use `filesize_approx` to estimate storage requirements before downloading.
# Is Short
Source: https://docs.tornadoapi.io/api-reference/metadata/is-short
POST /is-short
Detect whether a YouTube URL is a Short (vertical) or a regular video
## Overview
Classify a YouTube URL as a **Short** (vertical) or a regular **video** by inspecting the actual video dimensions. No download is performed.
This is more reliable than checking the URL pattern: a URL of the form `youtube.com/watch?v=...` can still be a Short in disguise (and vice versa). Only the real video dimensions tell the truth.
## How the classification works
* `is_short = height > width` on the best available video format
* `video_type` is `"live"` when the URL is a live stream, otherwise `"short"` when vertical, otherwise `"video"`
* `duration_seconds` is returned for context but does **not** affect the decision
The native extractor reads classification metadata before checking whether playback is available. A confirmed live stream can return `video_type: "live"` even before it starts. When dimensions are unavailable for that live, dimension fields are `null` and `is_short` is `false`.
If the lightweight extraction cannot classify the video, the API tries the full native extractor with a new proxy session. An ordinary video whose dimensions remain unknown returns `502` instead of a guessed classification.
## Header Parameters
Your API key for authentication
## Request
The YouTube URL to classify. Accepts every YouTube URL form: `watch?v=`, `youtu.be/`, `/shorts/`, `/embed/`. URLs from other platforms return `400`.
## Response
`true` when the video is vertical (height greater than width).
One of `"short"`, `"video"`, or `"live"`.
Width in pixels of the best available video format. May be `null` for a confirmed live stream with no available video format.
Height in pixels of the best available video format. May be `null` for a confirmed live stream with no available video format.
`height / width`. Values greater than `1.0` mean vertical. May be `null` when dimensions are missing.
Total video duration in seconds. Informational only — not used in the classification.
## Examples
```bash Detect a Short theme={null}
curl -X POST "https://api.tornadoapi.io/is-short" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/shorts/SXHMnicI6Pg"
}'
```
```javascript Node.js theme={null}
const response = await fetch('https://api.tornadoapi.io/is-short', {
method: 'POST',
headers: {
'x-api-key': 'sk_your_api_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=gUmlkXlhZo8'
})
});
const result = await response.json();
if (result.is_short) {
console.log(`Short: ${result.width}x${result.height}, ${result.duration_seconds}s`);
} else {
console.log(`Regular video: ${result.video_type}`);
}
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.tornadoapi.io/is-short',
headers={'x-api-key': 'sk_your_api_key'},
json={'url': 'https://www.youtube.com/watch?v=gUmlkXlhZo8'}
)
result = response.json()
print(f"Type: {result['video_type']}")
print(f"Dimensions: {result['width']}x{result['height']}")
print(f"Aspect ratio: {result['aspect_ratio']}")
```
## Success Response
```json 200 — Short theme={null}
{
"is_short": true,
"video_type": "short",
"width": 1080,
"height": 1920,
"aspect_ratio": 1.778,
"duration_seconds": 33
}
```
```json 200 — Regular video theme={null}
{
"is_short": false,
"video_type": "video",
"width": 1920,
"height": 1080,
"aspect_ratio": 0.5625,
"duration_seconds": 213
}
```
```json 200 — Live stream theme={null}
{
"is_short": false,
"video_type": "live",
"width": 1920,
"height": 1080,
"aspect_ratio": 0.5625,
"duration_seconds": 0
}
```
## Error Responses
```json 400 Bad Request — Not a YouTube URL theme={null}
{
"error": "Not a YouTube URL"
}
```
```json 400 Bad Request — Video unavailable theme={null}
{
"error": "Video is private"
}
```
```json 401 Unauthorized — Missing key theme={null}
{
"error": "Missing API key"
}
```
```json 401 Unauthorized — Invalid key theme={null}
{
"error": "Invalid API Key"
}
```
```json 502 Bad Gateway — Upstream failure theme={null}
{
"error": "Temporary YouTube error, please retry"
}
```
The 400 response covers two cases: the URL is not a recognised YouTube URL, or classification metadata is unavailable and the full native extractor confirms a content restriction (private, deleted, region-blocked, channel terminated). A refusal from the lightweight client alone triggers a fallback.
The 401 response covers two cases: no `x-api-key` header was sent, or the key is invalid / revoked.
The 502 response means the extractor could not establish a classification (proxy issue, YouTube error, timeout, or missing dimensions). Retry after a short delay.
## Use Cases
### Conditional routing
Pick a different quality preset, container, or storage folder based on whether the video is a Short.
### Pre-filtering a batch
When ingesting a creator's catalog, separate Shorts from full videos before queueing downloads.
### UI hints
Show a different player or thumbnail aspect ratio in your front-end as soon as the URL is pasted.
## Notes
* **Extraction budgets**: up to 6 seconds for lightweight extraction and 20 seconds for the full native fallback when needed.
* **No server-side cache**: if you call the endpoint repeatedly for the same URL, consider caching the response on your side.
* **No download**: only video information is fetched; storage and bandwidth are not charged for this call.
# Spotify Show Has Video
Source: https://docs.tornadoapi.io/api-reference/metadata/spotify-has-video
GET /spotify/show/{id}/hasVideo
Check whether a Spotify podcast show has any video episodes
## Overview
Return whether a Spotify **show** (podcast) has **any video episode**. No download is performed — the answer is read from Spotify's episode metadata (`mediaTypes`), so it works even though the audio tracks are DRM-protected.
Each episode advertises a `mediaTypes` list: an episode with a video variant is `["VIDEO", "AUDIO"]`, an audio-only episode is `["AUDIO"]`. The show `has_video` when **at least one** episode contains `VIDEO`.
## Path Parameters
The Spotify show id — the base62 id from a show URL, e.g. `11VjrLJfoiNvgjjqov4RWh` in `https://open.spotify.com/show/11VjrLJfoiNvgjjqov4RWh`.
## Header Parameters
Your API key for authentication.
## Response
The Spotify show id you queried.
`true` when at least one episode exposes a video variant.
How many episodes were inspected to reach the answer. When answered from the episode list (the normal path) this equals `total_episodes`.
Total number of episodes the show lists.
`true` when the answer is authoritative (every episode was inspected, or a video episode was found). When `false`, a `has_video: false` only means "no video episode was seen in the portion inspected" — not a guarantee the whole show is audio-only.
`true` when the response was served from the 24-hour server-side cache. Cached responses are **not** billed.
## Examples
```bash cURL theme={null}
curl "https://api.tornadoapi.io/spotify/show/11VjrLJfoiNvgjjqov4RWh/hasVideo" \
-H "x-api-key: sk_your_api_key"
```
```javascript Node.js theme={null}
const showId = '11VjrLJfoiNvgjjqov4RWh';
const response = await fetch(
`https://api.tornadoapi.io/spotify/show/${showId}/hasVideo`,
{ headers: { 'x-api-key': 'sk_your_api_key' } }
);
const result = await response.json();
if (result.has_video) {
console.log(`This show has video episodes (${result.total_episodes} episodes total).`);
} else if (result.determinate) {
console.log('This show is audio-only.');
}
```
```python Python theme={null}
import requests
show_id = "11VjrLJfoiNvgjjqov4RWh"
response = requests.get(
f"https://api.tornadoapi.io/spotify/show/{show_id}/hasVideo",
headers={"x-api-key": "sk_your_api_key"},
)
result = response.json()
print(f"has_video: {result['has_video']} ({result['total_episodes']} episodes)")
```
## Success Response
```json 200 — Video podcast theme={null}
{
"show_id": "11VjrLJfoiNvgjjqov4RWh",
"has_video": true,
"episodes_scanned": 100,
"total_episodes": 100,
"determinate": true,
"cached": false
}
```
```json 200 — Audio-only podcast theme={null}
{
"show_id": "2zzBL9Oe9bygUGlOban5fW",
"has_video": false,
"episodes_scanned": 157,
"total_episodes": 157,
"determinate": true,
"cached": false
}
```
```json 200 — Served from cache theme={null}
{
"show_id": "11VjrLJfoiNvgjjqov4RWh",
"has_video": true,
"episodes_scanned": 100,
"total_episodes": 100,
"determinate": true,
"cached": true
}
```
## Error Responses
```json 400 Bad Request — Invalid show id theme={null}
{
"error": "Invalid Spotify show id"
}
```
```json 401 Unauthorized — Invalid key theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found — No episodes theme={null}
{
"error": "Show not found or has no episodes"
}
```
```json 502 Bad Gateway — Upstream failure theme={null}
{
"error": "Failed to resolve the show upstream"
}
```
The `502` response means a transient upstream issue while resolving the show (Spotify hiccup, token capture, or proxy). Retry after a short delay.
## Billing
Each **resolved** lookup (a cache miss) is billed as a flat **1 MiB of data transfer** — the same unit as downloads, so it appears on your usage dashboard and invoice with no separate line item. Roughly **1,024 lookups = 1 GB**.
**Cache hits are free.** Results are cached server-side for **24 hours** per show; repeat lookups for the same show within that window are served instantly and are not billed.
## Use Cases
### Catalog pre-filtering
Before ingesting a show, decide whether to route it to a video or audio pipeline.
### UI hints
Badge a show as "Video" in your front-end as soon as a user pastes a Spotify show link.
## Notes
* **First lookup latency**: a cache miss takes a few seconds (it lists the show's episodes upstream). Subsequent lookups within 24 hours are instant (`cached: true`).
* **Mixed shows**: `has_video` is `true` if **any** episode has video, even when most episodes are audio-only.
* **Authoritative negatives**: a `has_video: false` with `determinate: true` means the whole show was inspected and no video episode exists.
# Configure Azure Blob Storage
Source: https://docs.tornadoapi.io/api-reference/user/configure-blob
POST /user/blob
Configure Azure Blob Storage for your downloads
## Overview
Set up Azure Blob Storage for uploaded videos. Authenticate with either an account key or a SAS token. Credentials are verified before saving.
## Header Parameters
Your API key for authentication
## Request Body
Azure Storage Account name
Blob container name
Storage Account access key (Base64 encoded). Required if `sas_token` is not provided.
SAS token for authentication. Required if `account_key` is not provided.
Optional folder prefix for organizing uploads (e.g., `downloads/2024/`)
Base folder name for uploaded files. Defaults to `videos` if not specified. Set to a custom value to change the top-level folder where files are stored (e.g., `downloads`, `media`).
Provide either `account_key` OR `sas_token`, not both. At least one must be provided.
## Response
Success confirmation message
Storage provider type: `blob`
The configured container name
The configured folder prefix (if provided)
## Examples
```bash Account Key theme={null}
curl -X POST "https://api.tornadoapi.io/user/blob" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"account_name": "mystorageaccount",
"container": "tornado-downloads",
"account_key": "xxxxxxxxxxxxxxxxxxxxxxxxxxx=="
}'
```
```bash SAS Token theme={null}
curl -X POST "https://api.tornadoapi.io/user/blob" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"account_name": "mystorageaccount",
"container": "tornado-downloads",
"sas_token": "sv=2022-11-02&ss=b&srt=co&sp=rwdlacyx&se=2025-12-31T23:59:59Z&sig=..."
}'
```
```python Python theme={null}
import requests
response = requests.post(
"https://api.tornadoapi.io/user/blob",
headers={"x-api-key": "sk_your_api_key"},
json={
"account_name": "mystorageaccount",
"container": "tornado-downloads",
"account_key": "xxxxxxxxxxxxxxxxxxxxxxxxxxx=="
}
)
print(response.json())
```
```json Response theme={null}
{
"message": "Azure Blob storage configured successfully",
"provider": "blob",
"container_or_bucket": "tornado-downloads"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "Azure Blob storage configured successfully",
"provider": "blob",
"container_or_bucket": "tornado-downloads"
}
```
## Error Responses
```json 400 Bad Request - Missing Auth theme={null}
{
"error": "Either account_key or sas_token must be provided"
}
```
```json 400 Bad Request - Invalid Credentials theme={null}
{
"error": "Credential validation failed: AuthenticationFailed"
}
```
```json 400 Bad Request - Invalid Key Format theme={null}
{
"error": "Account key must be valid Base64"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 503 Service Unavailable theme={null}
{
"error": "Azure Blob storage configuration is temporarily unavailable"
}
```
## Verification Process
When you submit storage configuration, Tornado:
1. Validates that either `account_key` or `sas_token` is provided
2. Creates a storage client with your credentials
3. Attempts to upload a small test file (`verify_credentials.txt`)
4. Deletes the test file
5. If successful, saves the configuration encrypted
## Required SAS Permissions
When using a SAS token, ensure these permissions are enabled:
* **Read** (r) - For generating download URLs
* **Write** (w) - For uploading files
* **Delete** (d) - For cleanup operations
* **List** (l) - For validation
# Configure Google Cloud Storage
Source: https://docs.tornadoapi.io/api-reference/user/configure-gcs
POST /user/gcs
Configure Google Cloud Storage for your downloads
## Overview
Set up Google Cloud Storage for uploaded videos. Authenticate with a service account JSON key. Credentials are verified before saving.
## Header Parameters
Your API key for authentication
## Request Body
Google Cloud project ID
GCS bucket name
Service account JSON credentials (as escaped string or Base64 encoded)
Optional folder prefix for organizing uploads (e.g., `downloads/2024/`)
Base folder name for uploaded files. Defaults to `videos` if not specified. Set to a custom value to change the top-level folder where files are stored (e.g., `downloads`, `media`).
## Response
Success confirmation message
Storage provider type: `gcs`
The configured bucket name
The configured folder prefix (if provided)
## Examples
```bash cURL theme={null}
curl -X POST "https://api.tornadoapi.io/user/gcs" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"project_id": "my-gcp-project",
"bucket": "tornado-downloads",
"service_account_json": "{\"type\":\"service_account\",\"project_id\":\"my-gcp-project\",\"private_key\":\"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n\",\"client_email\":\"tornado@my-gcp-project.iam.gserviceaccount.com\"}"
}'
```
```python Python (from file) theme={null}
import requests
import json
# Load service account JSON file (recommended)
with open("service-account.json") as f:
sa_json = json.dumps(json.load(f))
response = requests.post(
"https://api.tornadoapi.io/user/gcs",
headers={"x-api-key": "sk_your_api_key"},
json={
"project_id": "my-gcp-project",
"bucket": "tornado-downloads",
"service_account_json": sa_json
}
)
print(response.json())
```
```python Python (inline) theme={null}
import requests
import json
# Build as a dict and serialize with json.dumps()
service_account = {
"type": "service_account",
"project_id": "my-gcp-project",
"private_key_id": "key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "tornado@my-gcp-project.iam.gserviceaccount.com",
"client_id": "123456789",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}
response = requests.post(
"https://api.tornadoapi.io/user/gcs",
headers={"x-api-key": "sk_your_api_key"},
json={
"project_id": "my-gcp-project",
"bucket": "tornado-downloads",
"service_account_json": json.dumps(service_account)
}
)
print(response.json())
```
```json Response theme={null}
{
"message": "Google Cloud Storage configured successfully",
"provider": "gcs",
"container_or_bucket": "tornado-downloads"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "Google Cloud Storage configured successfully",
"provider": "gcs",
"container_or_bucket": "tornado-downloads"
}
```
## Error Responses
```json 400 Bad Request - Invalid JSON theme={null}
{
"error": "Invalid service account JSON: missing 'private_key' field"
}
```
```json 400 Bad Request - Invalid Credentials theme={null}
{
"error": "Credential validation failed: AuthenticationFailed"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 503 Service Unavailable theme={null}
{
"error": "GCS storage configuration is temporarily unavailable"
}
```
## Verification Process
When you submit storage configuration, Tornado:
1. Validates the request format and the service account JSON structure
2. Creates a storage client with your credentials
3. Attempts to upload a small test file (`verify_credentials.txt`)
4. Deletes the test file
5. If successful, saves the configuration encrypted
## Required GCS Permissions
The service account needs the **Storage Object Admin** role, which includes:
* `storage.objects.create`
* `storage.objects.delete`
* `storage.objects.get`
* `storage.objects.list`
For the `service_account_json` field, you can either:
* Pass the JSON as an escaped string
* Base64 encode the JSON file: `base64 -w0 service-account.json`
**Python users:** Do not manually construct the `service_account_json` as a raw string with escaped quotes.
The `\n` characters in the private key will be interpreted as literal newlines by Python, which produces
invalid JSON and causes a `"Invalid JSON in service account credentials"` error.
Always use `json.dumps()` on a dict or a loaded JSON file to ensure proper escaping:
```python theme={null}
# Correct: json.dumps() handles escaping
sa_json = json.dumps({"type": "service_account", "private_key": "-----BEGIN...\n...", ...})
# Wrong: manual string with \n becomes literal newlines
sa_json = '{"type": "service_account", "private_key": "-----BEGIN...\n..."}'
```
# Configure Google Drive
Source: https://docs.tornadoapi.io/api-reference/user/configure-gdrive
POST /user/gdrive
Configure Google Drive delivery for your downloads
## Overview
Deliver uploaded videos to a Google Drive folder using a **service account**. Credentials are verified with a live Drive API call before saving.
Google Drive uses **service account** authentication (the same kind of JSON key as GCS) and resumable uploads. The service account must have access to the target folder — share the Drive folder with the service account's email address.
## Header Parameters
Your API key for authentication
## Request Body
Service account JSON credentials (the entire JSON file content as a string).
Target Drive folder ID (the part after `/folders/` in the folder URL). Leave empty to upload to the service account's own Drive root. Default: `""` (root).
Optional folder prefix for organizing uploads (e.g., `downloads/2024/`)
Base folder name for uploaded files. Defaults to `videos` if not specified.
## Response
Success confirmation message
Storage provider type: `gdrive`
The configured target folder ID (empty when using the service account root)
The configured folder prefix (if provided)
## Examples
```bash cURL theme={null}
curl -X POST "https://api.tornadoapi.io/user/gdrive" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"folder_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
"service_account_json": "{\"type\":\"service_account\",\"project_id\":\"my-gcp-project\",\"private_key\":\"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n\",\"client_email\":\"tornado@my-gcp-project.iam.gserviceaccount.com\"}"
}'
```
```python Python (from file) theme={null}
import requests
import json
# Load service account JSON file (recommended)
with open("service-account.json") as f:
sa_json = json.dumps(json.load(f))
response = requests.post(
"https://api.tornadoapi.io/user/gdrive",
headers={"x-api-key": "sk_your_api_key"},
json={
"folder_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
"service_account_json": sa_json
}
)
print(response.json())
```
```json Response theme={null}
{
"message": "Google Drive configured successfully",
"provider": "gdrive",
"folder_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
"folder_prefix": null
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "Google Drive configured successfully",
"provider": "gdrive",
"folder_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
"folder_prefix": null
}
```
## Error Responses
```json 400 Bad Request - Invalid JSON theme={null}
{
"error": "Invalid configuration: invalid service account JSON"
}
```
```json 400 Bad Request - Validation Failed theme={null}
{
"error": "Credential validation failed: insufficient permissions on folder"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 503 Service Unavailable theme={null}
{
"error": "Google Drive storage configuration is temporarily unavailable"
}
```
## Setup
In Google Cloud Console, go to **IAM & Admin** > **Service Accounts** > **Create Service Account**, then create a **JSON key** under **Keys** > **Add Key**.
Enable the **Google Drive API** for the service account's project.
In Google Drive, share the destination folder with the service account's email address (`...@PROJECT.iam.gserviceaccount.com`) and grant **Editor** access. Copy the folder ID from the URL (`drive.google.com/drive/folders/`).
Send the `folder_id` and the minified `service_account_json` to `POST /user/gdrive`.
**A service account has no Drive storage quota at all**, and cannot own files.
Google states it plainly: service accounts "must upload files and folders into
shared drives, or use OAuth 2.0 to upload items on behalf of a human user".
So a folder shared from a personal My Drive does not work: the service account
owns whatever it creates, has no quota of its own, and the upload fails with
`storageQuotaExceeded`. Use a **Shared Drive**, where files are owned by the
drive and billed to the Workspace pooled storage, and add the service account
as **Content manager** so it can delete as well as create.
Google Drive is available as a **pre-configured** delivery target via this endpoint only. It is **not** supported as inline `storage` credentials in `POST /jobs` (inline storage supports S3, Azure Blob, GCS, and Alibaba OSS).
# Configure Alibaba OSS
Source: https://docs.tornadoapi.io/api-reference/user/configure-oss
POST /user/oss
Configure Alibaba Cloud Object Storage Service for your downloads
## Overview
Set up Alibaba Cloud OSS for uploaded videos. Credentials are verified before saving.
## Header Parameters
Your API key for authentication
## Request Body
OSS endpoint URL (e.g., `https://oss-cn-hangzhou.aliyuncs.com`)
OSS bucket name
OSS Access Key ID
OSS Access Key Secret
Optional folder prefix for organizing uploads (e.g., `downloads/2024/`)
Base folder name for uploaded files. Defaults to `videos` if not specified. Set to a custom value to change the top-level folder where files are stored (e.g., `downloads`, `media`).
## Response
Success confirmation message
Storage provider type: `oss`
The configured bucket name
The configured folder prefix (if provided)
## Examples
```bash cURL theme={null}
curl -X POST "https://api.tornadoapi.io/user/oss" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"bucket": "tornado-downloads",
"access_key_id": "LTAI5tXXXXXXXXXXXXXX",
"access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXX"
}'
```
```python Python theme={null}
import requests
response = requests.post(
"https://api.tornadoapi.io/user/oss",
headers={"x-api-key": "sk_your_api_key"},
json={
"endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"bucket": "tornado-downloads",
"access_key_id": "LTAI5tXXXXXXXXXXXXXX",
"access_key_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXX"
}
)
print(response.json())
```
```json Response theme={null}
{
"message": "Alibaba OSS storage configured successfully",
"provider": "oss",
"container_or_bucket": "tornado-downloads"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "Alibaba OSS storage configured successfully",
"provider": "oss",
"container_or_bucket": "tornado-downloads"
}
```
## Error Responses
```json 400 Bad Request - Invalid Credentials theme={null}
{
"error": "Credential validation failed: AccessDenied"
}
```
```json 400 Bad Request - Bucket Not Found theme={null}
{
"error": "Credential validation failed: NoSuchBucket"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 503 Service Unavailable theme={null}
{
"error": "OSS storage configuration is temporarily unavailable"
}
```
## OSS Endpoint Regions
| Region | Endpoint |
| ---------------- | ----------------------------------------- |
| China (Hangzhou) | `https://oss-cn-hangzhou.aliyuncs.com` |
| China (Shanghai) | `https://oss-cn-shanghai.aliyuncs.com` |
| China (Beijing) | `https://oss-cn-beijing.aliyuncs.com` |
| Singapore | `https://oss-ap-southeast-1.aliyuncs.com` |
| US West | `https://oss-us-west-1.aliyuncs.com` |
| Germany | `https://oss-eu-central-1.aliyuncs.com` |
# Configure S3 Storage
Source: https://docs.tornadoapi.io/api-reference/user/configure-s3
POST /user/s3
Configure S3-compatible storage (AWS S3, Cloudflare R2, MinIO, etc.)
## Overview
Set up your own S3-compatible storage for uploaded videos. Credentials are verified before saving.
## Header Parameters
Your API key for authentication
## Request Body
S3 endpoint URL (e.g., `https://s3.us-east-1.amazonaws.com` or `https://ACCOUNT_ID.r2.cloudflarestorage.com`)
Bucket name
AWS region (e.g., `us-east-1`, `auto` for R2)
AWS Access Key ID or equivalent
AWS Secret Access Key or equivalent
Optional folder prefix for organizing uploads (e.g., `downloads/2024/`)
Base folder name for uploaded files. Defaults to `videos` if not specified. Set to a custom value to change the top-level folder where files are stored (e.g., `downloads`, `media`).
## Response
Success confirmation message
Storage provider type: `s3`
The configured bucket name
The configured folder prefix (if provided)
## Examples
```bash AWS S3 theme={null}
curl -X POST "https://api.tornadoapi.io/user/s3" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://s3.us-east-1.amazonaws.com",
"bucket": "my-tornado-downloads",
"region": "us-east-1",
"access_key": "AKIAIOSFODNN7EXAMPLE",
"secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"folder_prefix": "videos/"
}'
```
```bash Cloudflare R2 theme={null}
curl -X POST "https://api.tornadoapi.io/user/s3" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://ACCOUNT_ID.r2.cloudflarestorage.com",
"bucket": "my-downloads",
"region": "auto",
"access_key": "R2_ACCESS_KEY_ID",
"secret_key": "R2_SECRET_ACCESS_KEY"
}'
```
```python Python theme={null}
import requests
response = requests.post(
"https://api.tornadoapi.io/user/s3",
headers={"x-api-key": "sk_your_api_key"},
json={
"endpoint": "https://s3.us-east-1.amazonaws.com",
"bucket": "my-tornado-downloads",
"region": "us-east-1",
"access_key": "AKIAIOSFODNN7EXAMPLE",
"secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
)
print(response.json())
```
```json Response theme={null}
{
"message": "S3 storage configured successfully",
"provider": "s3",
"container_or_bucket": "my-tornado-downloads",
"folder_prefix": "videos/"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "S3 storage configured successfully",
"provider": "s3",
"container_or_bucket": "my-tornado-downloads",
"folder_prefix": "videos/"
}
```
## Error Responses
```json 400 Bad Request - Invalid Credentials theme={null}
{
"error": "Credential validation failed: Access Denied"
}
```
```json 400 Bad Request - Bucket Not Found theme={null}
{
"error": "Credential validation failed: NoSuchBucket"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
## Verification Process
When you submit storage configuration, Tornado:
1. Validates the request format and required fields
2. Creates a storage client with your credentials
3. Attempts to upload a small test file (`verify_credentials.txt`)
4. Deletes the test file
5. If successful, saves the configuration encrypted
Ensure your credentials have both **read and write** permissions before configuring.
## Required IAM Permissions
```json theme={null}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::your-bucket-name",
"arn:aws:s3:::your-bucket-name/*"
]
}
]
}
```
## Supported S3-Compatible Providers
| Provider | Endpoint Format | Region |
| ------------------- | ----------------------------------------------- | ----------- |
| AWS S3 | `https://s3.{region}.amazonaws.com` | Your region |
| Cloudflare R2 | `https://{account_id}.r2.cloudflarestorage.com` | `auto` |
| DigitalOcean Spaces | `https://{region}.digitaloceanspaces.com` | Your region |
| Backblaze B2 | `https://s3.{region}.backblazeb2.com` | Your region |
| Wasabi | `https://s3.{region}.wasabisys.com` | Your region |
| MinIO | Your MinIO URL | Your region |
| OVH Object Storage | `https://s3.{region}.cloud.ovh.net` | Your region |
# Configure Slack Webhook
Source: https://docs.tornadoapi.io/api-reference/user/configure-slack
POST /user/slack
Configure Slack notifications for job failures
## Overview
Configure a Slack incoming webhook to receive notifications when jobs fail. You can choose to receive **all** failure notifications, **errors only** (technical failures), or **warnings only** (content issues like private/unavailable videos). The webhook URL is stored securely and encrypted at rest.
## Header Parameters
Your API key for authentication
## Request
Slack incoming webhook URL. Must start with `https://hooks.slack.com/services/`.
Controls which failure types trigger a Slack notification:
* `all` — Both errors and warnings (default)
* `errors_only` — Only technical failures (rate limits, bot detection, connection errors)
* `warnings_only` — Only content issues (private video, members-only, geo-blocked, unavailable)
## Examples
```bash All notifications (default) theme={null}
curl -X POST "https://api.tornadoapi.io/user/slack" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
}'
```
```bash Errors only (skip warnings) theme={null}
curl -X POST "https://api.tornadoapi.io/user/slack" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
"notify_level": "errors_only"
}'
```
```bash Warnings only (content issues) theme={null}
curl -X POST "https://api.tornadoapi.io/user/slack" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
"notify_level": "warnings_only"
}'
```
## Success Response
```json 200 OK theme={null}
{
"message": "Slack webhook configured successfully",
"configured": true,
"notify_level": "all"
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "Slack webhook URL must start with https://hooks.slack.com/services/"
}
```
```json 400 Bad Request theme={null}
{
"error": "Invalid notify_level. Must be 'all', 'errors_only', or 'warnings_only'"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 503 Service Unavailable theme={null}
{
"error": "Slack webhook configuration is temporarily unavailable"
}
```
If you already have a Slack webhook configured, calling this endpoint again will replace the existing webhook and notify level.
## Notification Format
When a job fails, Tornado sends a Slack message with a colored attachment:
* **Red** (`:x:`) for technical errors (rate limits, connection issues, bot detection)
* **Orange** (`:warning:`) for content warnings (private video, members-only, geo-blocked, unavailable)
The message includes the **Job ID**, a **sanitized error category** (no internal infrastructure details are exposed), and the **source URL**.
## Notify Level Reference
| Level | Errors (red) | Warnings (orange) |
| --------------- | :----------: | :---------------: |
| `all` | ✓ | ✓ |
| `errors_only` | ✓ | ✗ |
| `warnings_only` | ✗ | ✓ |
### What counts as an error vs warning?
| Type | Examples |
| --------------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Error** (technical) | Bot detection, rate limited, connection error, upload failed, processing failed |
| **Warning** (content) | Private video, members-only, video unavailable, age-restricted, geo-blocked, channel terminated, copyright restricted |
This API route configures failure notifications. Completion summaries can also be enabled through the dashboard configuration; they are separate from `notify_level` and may aggregate several completed jobs.
# Remove Azure Blob Storage
Source: https://docs.tornadoapi.io/api-reference/user/delete-blob
DELETE /user/blob
Remove Azure Blob Storage configuration
## Overview
Remove your Azure Blob Storage configuration and revert to Tornado's managed storage for new uploads.
Existing files in your Azure container remain untouched. Only new uploads are affected.
## Header Parameters
Your API key for authentication
## Response
Success confirmation message
## Example
```bash cURL theme={null}
curl -X DELETE "https://api.tornadoapi.io/user/blob" \
-H "x-api-key: sk_your_api_key"
```
```python Python theme={null}
import requests
response = requests.delete(
"https://api.tornadoapi.io/user/blob",
headers={"x-api-key": "sk_your_api_key"}
)
print(response.json())
```
```javascript JavaScript theme={null}
const response = await fetch("https://api.tornadoapi.io/user/blob", {
method: "DELETE",
headers: { "x-api-key": "sk_your_api_key" }
});
const data = await response.json();
```
```json Response theme={null}
{
"message": "Azure Blob storage configuration removed"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "Azure Blob storage configuration removed"
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found theme={null}
{
"error": "No Azure Blob configuration found"
}
```
# Remove Google Cloud Storage
Source: https://docs.tornadoapi.io/api-reference/user/delete-gcs
DELETE /user/gcs
Remove Google Cloud Storage configuration
## Overview
Remove your Google Cloud Storage configuration and revert to Tornado's managed storage for new uploads.
Existing files in your GCS bucket remain untouched. Only new uploads are affected.
## Header Parameters
Your API key for authentication
## Response
Success confirmation message
## Example
```bash cURL theme={null}
curl -X DELETE "https://api.tornadoapi.io/user/gcs" \
-H "x-api-key: sk_your_api_key"
```
```python Python theme={null}
import requests
response = requests.delete(
"https://api.tornadoapi.io/user/gcs",
headers={"x-api-key": "sk_your_api_key"}
)
print(response.json())
```
```javascript JavaScript theme={null}
const response = await fetch("https://api.tornadoapi.io/user/gcs", {
method: "DELETE",
headers: { "x-api-key": "sk_your_api_key" }
});
const data = await response.json();
```
```json Response theme={null}
{
"message": "GCS storage configuration removed"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "GCS storage configuration removed"
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found theme={null}
{
"error": "No GCS configuration found"
}
```
# Remove Google Drive
Source: https://docs.tornadoapi.io/api-reference/user/delete-gdrive
DELETE /user/gdrive
Remove Google Drive delivery configuration
## Overview
Remove your Google Drive delivery configuration and revert to Tornado's managed storage for new uploads.
Existing files already delivered to your Drive folder remain untouched. Only new uploads are affected.
## Header Parameters
Your API key for authentication
## Response
Success confirmation message
## Example
```bash cURL theme={null}
curl -X DELETE "https://api.tornadoapi.io/user/gdrive" \
-H "x-api-key: sk_your_api_key"
```
```python Python theme={null}
import requests
response = requests.delete(
"https://api.tornadoapi.io/user/gdrive",
headers={"x-api-key": "sk_your_api_key"}
)
print(response.json())
```
```json Response theme={null}
{
"message": "Google Drive storage configuration removed"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "Google Drive storage configuration removed"
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found theme={null}
{
"error": "No Google Drive configuration found"
}
```
# Remove Alibaba OSS
Source: https://docs.tornadoapi.io/api-reference/user/delete-oss
DELETE /user/oss
Remove Alibaba Cloud OSS configuration
## Overview
Remove your Alibaba Cloud OSS configuration and revert to Tornado's managed storage for new uploads.
Existing files in your OSS bucket remain untouched. Only new uploads are affected.
## Header Parameters
Your API key for authentication
## Response
Success confirmation message
## Example
```bash cURL theme={null}
curl -X DELETE "https://api.tornadoapi.io/user/oss" \
-H "x-api-key: sk_your_api_key"
```
```python Python theme={null}
import requests
response = requests.delete(
"https://api.tornadoapi.io/user/oss",
headers={"x-api-key": "sk_your_api_key"}
)
print(response.json())
```
```javascript JavaScript theme={null}
const response = await fetch("https://api.tornadoapi.io/user/oss", {
method: "DELETE",
headers: { "x-api-key": "sk_your_api_key" }
});
const data = await response.json();
```
```json Response theme={null}
{
"message": "OSS storage configuration removed"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "OSS storage configuration removed"
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found theme={null}
{
"error": "No OSS configuration found"
}
```
# Remove S3 Storage
Source: https://docs.tornadoapi.io/api-reference/user/delete-s3
DELETE /user/s3
Remove S3-compatible storage configuration
## Overview
Remove your S3-compatible storage configuration and revert to Tornado's managed storage for new uploads.
Existing files in your S3 bucket remain untouched. Only new uploads are affected.
## Header Parameters
Your API key for authentication
## Response
Success confirmation message
## Example
```bash cURL theme={null}
curl -X DELETE "https://api.tornadoapi.io/user/s3" \
-H "x-api-key: sk_your_api_key"
```
```python Python theme={null}
import requests
response = requests.delete(
"https://api.tornadoapi.io/user/s3",
headers={"x-api-key": "sk_your_api_key"}
)
print(response.json())
```
```javascript JavaScript theme={null}
const response = await fetch("https://api.tornadoapi.io/user/s3", {
method: "DELETE",
headers: { "x-api-key": "sk_your_api_key" }
});
const data = await response.json();
```
```json Response theme={null}
{
"message": "S3 storage configuration removed"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "S3 storage configuration removed"
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found theme={null}
{
"error": "No S3 configuration found"
}
```
# Delete Slack Webhook
Source: https://docs.tornadoapi.io/api-reference/user/delete-slack
DELETE /user/slack
Remove Slack webhook notification configuration
## Overview
Removes the Slack webhook notification configuration. You will no longer receive Slack notifications for failed jobs.
## Header Parameters
Your API key for authentication
## Examples
```bash Request theme={null}
curl -X DELETE "https://api.tornadoapi.io/user/slack" \
-H "x-api-key: sk_your_api_key"
```
## Success Response
```json 200 OK theme={null}
{
"message": "Slack webhook removed"
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
```json 404 Not Found theme={null}
{
"error": "No Slack webhook configured"
}
```
# Get S3 Storage
Source: https://docs.tornadoapi.io/api-reference/user/get-s3
GET /user/s3
Retrieve the current S3-compatible storage configuration for your API key
## Overview
Returns the S3-compatible storage configuration currently saved for your API key — whatever was last set via [`POST /user/s3`](/api-reference/user/configure-s3) (or the deprecated [`/user/bucket`](/api-reference/user/update-bucket) endpoint). Use this to confirm what's configured, or to check whether a key has any custom storage at all, without needing to run a test job.
If nothing is configured, this returns `200 OK` with `"configured": false` — not an error. A key with no custom storage is a normal state; it simply falls back to Tornado's managed storage.
This endpoint never returns your `secret_key`, and never returns a full `access_key` — see [Security](#security) below.
## Header Parameters
Your API key for authentication
## Response
Whether this key has any S3 storage configured. All other fields are only present when `true`.
Storage provider type: `s3`
The configured bucket name
The configured folder prefix, if any
The configured base folder (only present when set via the modern `/user/s3` path)
The configured S3 endpoint URL. Only present in **legacy** mode (the `source` field below explains the difference) — the modern, Key Vault-backed path doesn't hold the endpoint outside the vault.
The configured region. Only present in **legacy** mode.
The last 4 characters of your access key ID, prefixed with `****` (e.g. `****MPLE`). Only present in **legacy** mode. Never the full key — see [Security](#security).
Unix timestamp (seconds) of when this configuration was last saved. Only present in modern (Key Vault) mode.
Which storage location this configuration came from:
* `keyvault` — saved via `POST /user/s3` with Key Vault enabled (the default, modern path). Credentials live in Azure Key Vault; this service never holds them outside of upload time, so `endpoint`, `region`, and `access_key_masked` aren't available to return.
* `legacy` — saved via the fallback path used when Key Vault is disabled (or the deprecated `/user/bucket` endpoint). Credentials are stored on the API key document; `access_key` is shown masked, `secret_key` is never returned.
If both happen to exist for a key, `keyvault` always wins — this matches exactly which one is actually used for uploads.
## Examples
```bash cURL theme={null}
curl -X GET "https://api.tornadoapi.io/user/s3" \
-H "x-api-key: sk_your_api_key"
```
```python Python theme={null}
import requests
response = requests.get(
"https://api.tornadoapi.io/user/s3",
headers={"x-api-key": "sk_your_api_key"}
)
print(response.json())
```
```javascript JavaScript theme={null}
const response = await fetch("https://api.tornadoapi.io/user/s3", {
method: "GET",
headers: { "x-api-key": "sk_your_api_key" }
});
const data = await response.json();
```
```json Response (configured, Key Vault) theme={null}
{
"configured": true,
"provider": "s3",
"container_or_bucket": "my-tornado-downloads",
"folder_prefix": "videos/",
"base_folder": "videos",
"last_modified": 1751980800,
"source": "keyvault"
}
```
```json Response (configured, legacy) theme={null}
{
"configured": true,
"provider": "s3",
"container_or_bucket": "my-tornado-downloads",
"endpoint": "https://s3.us-east-1.amazonaws.com",
"region": "us-east-1",
"access_key_masked": "****MPLE",
"source": "legacy"
}
```
```json Response (not configured) theme={null}
{
"configured": false
}
```
## Success Response
```json 200 OK theme={null}
{
"configured": true,
"provider": "s3",
"container_or_bucket": "my-tornado-downloads",
"folder_prefix": "videos/",
"base_folder": "videos",
"last_modified": 1751980800,
"source": "keyvault"
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
## Security
There is no `secret_key` or `secret_key_masked` field in the response at all, in either mode. It's write-only from the API's perspective — set it via `POST /user/s3`, but it can never be read back.
In legacy mode, only the last 4 characters are returned (`access_key_masked`, e.g. `****MPLE`). Values 4 characters or shorter are fully masked (`****`) rather than echoed whole. In Key Vault mode, the access key isn't returned at all — this service doesn't hold it outside of upload time.
The internal Key Vault secret name (an infrastructure detail, not a credential, but still sensitive) is never included in the response.
## Checking Configuration Programmatically
```python theme={null}
import requests
def get_storage_config(api_key):
response = requests.get(
"https://api.tornadoapi.io/user/s3",
headers={"x-api-key": api_key}
)
data = response.json()
if not data["configured"]:
print("No custom S3 storage configured — using Tornado's managed storage")
return
print(f"Bucket: {data['container_or_bucket']}")
print(f"Source: {data['source']}") # "keyvault" or "legacy"
if "access_key_masked" in data:
print(f"Access key: {data['access_key_masked']}")
get_storage_config("sk_your_api_key")
```
## Related Endpoints
Set or update your S3-compatible storage configuration
Remove your S3 configuration and revert to Tornado's managed storage
# Get Usage
Source: https://docs.tornadoapi.io/api-reference/user/get-usage
GET /usage
Get your API usage statistics
## Overview
Returns usage statistics for your API key, including job count and storage usage.
## Header Parameters
Your API key for authentication
## Response
Your account/client name
Total number of jobs created
Total storage used in gigabytes
Storage currently reserved by in-progress jobs (in gigabytes). Released when jobs complete or fail.
Effective storage usage: `storage_usage_gb + storage_reserved_gb`. Used for quota enforcement.
Start of current billing period (if Stripe billing enabled)
End of current billing period (if Stripe billing enabled)
Storage used in current billing period (if Stripe billing enabled)
Storage limit in gigabytes (only present for trial/limited keys)
Storage limit in bytes (only present for trial/limited keys)
Remaining storage in gigabytes before quota is reached (only present for trial/limited keys)
Error message when billing info retrieval fails (only present when Stripe billing is configured and an error occurs)
Maximum video resolution allowed for this API key (e.g., `"720"`, `"1080"`). Only present if a resolution limit is configured.
Maximum video duration in seconds allowed for this API key (e.g., `600` for 10 min). Only present if a duration limit is configured.
Maximum file size per video in bytes. Only present if a file size limit is configured.
Maximum file size per video in megabytes. Only present if a file size limit is configured.
## Example
```bash Request theme={null}
curl -X GET "https://api.tornadoapi.io/usage" \
-H "x-api-key: sk_your_api_key"
```
```json Response theme={null}
{
"client_name": "My Application",
"usage_count": 1523,
"storage_usage_gb": 45.67,
"storage_reserved_gb": 0.52,
"storage_effective_usage_gb": 46.19
}
```
```json Response (With Billing) theme={null}
{
"client_name": "My Application",
"usage_count": 1523,
"storage_usage_gb": 45.67,
"storage_reserved_gb": 0.52,
"storage_effective_usage_gb": 46.19,
"billing_period_start": "2024-01-01T00:00:00Z",
"billing_period_end": "2024-02-01T00:00:00Z",
"current_period_usage_gb": 12.34
}
```
```json Response (Trial Key with Storage Limit) theme={null}
{
"client_name": "My Trial Account",
"usage_count": 42,
"storage_usage_gb": 3.0,
"storage_reserved_gb": 0.0,
"storage_effective_usage_gb": 3.0,
"storage_limit_gb": 1024.0,
"storage_limit_bytes": 1099511627776,
"storage_remaining_gb": 1021.0
}
```
```json Response (Key with Limits) theme={null}
{
"client_name": "Limited Key",
"usage_count": 100,
"storage_usage_gb": 5.0,
"storage_reserved_gb": 0.0,
"storage_effective_usage_gb": 5.0,
"max_resolution_limit": "1080",
"max_duration_limit_seconds": 600,
"max_filesize_limit_bytes": 524288000,
"max_filesize_limit_mb": 500.0
}
```
## Success Response
```json 200 OK theme={null}
{
"client_name": "My Application",
"usage_count": 1523,
"storage_usage_gb": 45.67,
"storage_reserved_gb": 0.52,
"storage_effective_usage_gb": 46.19
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
## Usage Tracking
### Job Count
Incremented each time you call `POST /jobs`. For batch operations, each episode counts as one job.
### Storage Usage
Cumulative total of all files uploaded to S3. This includes:
* Successfully completed videos
* All formats (MP4, MKV, MP3)
Deleting files from S3 does not decrease the storage counter. This metric tracks total data transferred.
### Storage Quota (Trial Keys)
API keys with a storage limit (e.g., 1 TB trial keys) include quota information in the response.
When the limit is reached, new jobs are rejected with a `403 Forbidden` error:
```json theme={null}
{
"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"
}
```
The response fields vary depending on your key type:
| Field | Stripe Key | Trial Key | No Limit |
| ---------------------------- | :--------: | :-------: | :------: |
| `client_name` | ✓ | ✓ | ✓ |
| `usage_count` | ✓ | ✓ | ✓ |
| `storage_usage_gb` | ✓ | ✓ | ✓ |
| `storage_reserved_gb` | ✓ | ✓ | ✓ |
| `storage_effective_usage_gb` | ✓ | ✓ | ✓ |
| `billing_period_start` | ✓ | — | — |
| `billing_period_end` | ✓ | — | — |
| `current_period_usage_gb` | ✓ | — | — |
| `billing_error` | on error | — | — |
| `storage_limit_gb` | — | ✓ | — |
| `storage_limit_bytes` | — | ✓ | — |
| `storage_remaining_gb` | — | ✓ | — |
| `max_resolution_limit` | if set | if set | if set |
| `max_duration_limit_seconds` | if set | if set | if set |
| `max_filesize_limit_bytes` | if set | if set | if set |
| `max_filesize_limit_mb` | if set | if set | if set |
## Monitoring Usage
```python theme={null}
import requests
def check_usage(api_key):
response = requests.get(
"https://api.tornadoapi.io/usage",
headers={"x-api-key": api_key}
)
data = response.json()
print(f"Client: {data['client_name']}")
print(f"Jobs: {data['usage_count']}")
print(f"Storage: {data['storage_usage_gb']:.2f} GB")
# Trial key with storage limit
if "storage_limit_gb" in data:
print(f"Limit: {data['storage_limit_gb']:.2f} GB")
print(f"Remaining: {data['storage_remaining_gb']:.2f} GB")
# Stripe billing key
if "current_period_usage_gb" in data:
print(f"Current period: {data['current_period_usage_gb']:.2f} GB")
check_usage("sk_your_api_key")
```
# Reset Bucket Config (Legacy)
Source: https://docs.tornadoapi.io/api-reference/user/reset-bucket
DELETE /user/bucket
Reset to Tornado default storage (deprecated)
**Deprecated**: This endpoint only supports S3-compatible storage. Use the dedicated endpoints instead:
* [`DELETE /user/s3`](/api-reference/user/delete-s3) for S3-compatible storage
* [`DELETE /user/blob`](/api-reference/user/delete-blob) for Azure Blob Storage
* [`DELETE /user/gcs`](/api-reference/user/delete-gcs) for Google Cloud Storage
* [`DELETE /user/oss`](/api-reference/user/delete-oss) for Alibaba Cloud OSS
## Overview
Remove your custom S3 bucket configuration and revert to using Tornado's default managed storage.
## Header Parameters
Your API key for authentication
## Request
No request body required. Simply send a DELETE request with your API key.
## Response
Success confirmation message
## Example
```bash cURL theme={null}
curl -X DELETE "https://api.tornadoapi.io/user/bucket" \
-H "x-api-key: sk_your_api_key"
```
```javascript Node.js theme={null}
const response = await fetch('https://api.tornadoapi.io/user/bucket', {
method: 'DELETE',
headers: {
'x-api-key': 'sk_your_api_key'
}
});
const data = await response.json();
console.log(data);
```
```python Python theme={null}
import requests
response = requests.delete(
'https://api.tornadoapi.io/user/bucket',
headers={'x-api-key': 'sk_your_api_key'}
)
print(response.json())
```
```json Response theme={null}
{
"message": "Bucket configuration reset to default"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "Bucket configuration reset to default"
}
```
## Error Responses
```json 401 Unauthorized theme={null}
{
"error": "Missing x-api-key header"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
## When to Use
* You want to stop using your own S3 bucket
* Your bucket credentials have changed and you want to reconfigure
* You're troubleshooting storage issues
After resetting, all new downloads will be stored in Tornado's managed storage. Existing files in your bucket remain untouched.
# Update Bucket Config (Legacy)
Source: https://docs.tornadoapi.io/api-reference/user/update-bucket
POST /user/bucket
Configure your own S3-compatible storage bucket (deprecated)
**Deprecated**: This endpoint only supports S3-compatible storage. Use the dedicated endpoints instead:
* [`POST /user/s3`](/api-reference/user/configure-s3) for S3-compatible storage
* [`POST /user/blob`](/api-reference/user/configure-blob) for Azure Blob Storage
* [`POST /user/gcs`](/api-reference/user/configure-gcs) for Google Cloud Storage
* [`POST /user/oss`](/api-reference/user/configure-oss) for Alibaba Cloud OSS
## Overview
Configure a custom S3-compatible storage bucket for your downloads. Credentials are verified before saving.
## Header Parameters
Your API key for authentication
## Request
S3 endpoint URL (e.g., `https://s3.us-east-1.amazonaws.com`)
Bucket name
AWS region (e.g., `us-east-1`, `auto` for R2)
AWS Access Key ID or equivalent
AWS Secret Access Key or equivalent
## Response
Success confirmation message
## Example
```bash AWS S3 theme={null}
curl -X POST "https://api.tornadoapi.io/user/bucket" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://s3.us-east-1.amazonaws.com",
"bucket": "my-tornado-downloads",
"region": "us-east-1",
"access_key": "AKIAIOSFODNN7EXAMPLE",
"secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}'
```
```bash Cloudflare R2 theme={null}
curl -X POST "https://api.tornadoapi.io/user/bucket" \
-H "x-api-key: sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://ACCOUNT_ID.r2.cloudflarestorage.com",
"bucket": "my-downloads",
"region": "auto",
"access_key": "R2_ACCESS_KEY_ID",
"secret_key": "R2_SECRET_ACCESS_KEY"
}'
```
```json Response theme={null}
{
"message": "Bucket configuration updated successfully"
}
```
## Success Response
```json 200 OK theme={null}
{
"message": "Bucket configuration updated successfully"
}
```
## Error Responses
```json 400 Bad Request theme={null}
{
"error": "Verification failed: Access Denied"
}
```
```json 400 Bad Request theme={null}
{
"error": "Verification failed: NoSuchBucket"
}
```
```json 401 Unauthorized theme={null}
{
"error": "Invalid API Key"
}
```
## Verification Process
When you submit bucket configuration, Tornado:
1. Creates an S3 client with your credentials
2. Attempts to list the bucket (verifies read access)
3. If successful, saves the configuration
Ensure your credentials have both read and write permissions before configuring.
## Required IAM Permissions
```json theme={null}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::your-bucket-name",
"arn:aws:s3:::your-bucket-name/*"
]
}
]
}
```
## Supported Providers
| Provider | Endpoint Format | Region |
| ------------------- | ----------------------------------------------- | ----------- |
| AWS S3 | `https://s3.{region}.amazonaws.com` | Your region |
| Cloudflare R2 | `https://{account_id}.r2.cloudflarestorage.com` | `auto` |
| OVH Object Storage | `https://s3.{region}.cloud.ovh.net` | Your region |
| MinIO | Your MinIO URL | Your region |
| DigitalOcean Spaces | `https://{region}.digitaloceanspaces.com` | Your region |
| Backblaze B2 | `https://s3.{region}.backblazeb2.com` | Your region |
| Wasabi | `https://s3.{region}.wasabisys.com` | Your region |