Skip to main content

Overview

Webhooks allow you to receive HTTP notifications when jobs complete, eliminating the need for polling.

Setup

Include a webhook_url in your job request:
curl -X POST "https://api.tornadoapi.io/jobs" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://youtube.com/watch?v=...",
    "webhook_url": "https://your-app.com/webhooks/tornado"
  }'

Webhook Payload

When a job completes, Tornado sends a POST request to your webhook_url with a JSON payload. Here’s what your server will receive:

Single Job Completion

{
  "type": "job_completed",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "completed",
  "url": "https://youtube.com/watch?v=...",
  "key": "videos/my-video.mp4",
  "folder": "my-folder",
  "title": "My Video Title",
  "description": "Video description from the source platform",
  "release_date": "2026-01-19T06:00:00Z",
  "subtitle_key": "videos/my-video.en.vtt"
}
FieldTypeDescription
typestringAlways "job_completed"
job_idstringUUID of the job
statusstring"completed"
urlstringOriginal source URL
keystringStorage key of the uploaded file
folderstring | nullFolder prefix if provided at job creation
titlestring | nullVideo/episode title. Available for YouTube and Spotify
descriptionstring | nullContent description from YouTube. null for Spotify
release_datestring | nullISO 8601 date. Spotify: exact publish date. YouTube: upload date (midnight UTC)
subtitle_keystring | nullStorage key for subtitles (if download_subtitles was enabled)

Job Failed

Sent when a job fails due to a technical error (bot detection, rate limit, connection issues). Not sent for content warnings (private/members-only/geo-blocked videos) — those are handled silently.
{
  "type": "job_failed",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "failed",
  "url": "https://youtube.com/watch?v=...",
  "error": "Video unavailable"
}
FieldTypeDescription
typestringAlways "job_failed"
job_idstringUUID of the job
statusstring"failed"
urlstringOriginal source URL
errorstringError message

Job Skipped

Sent when a job is skipped because the content cannot be processed. This happens for audio-only Spotify episodes that are protected by Widevine DRM and have no video stream available. Skipped jobs are classified as warnings, not errors — they represent content limitations, not technical failures.
{
  "type": "job_skipped",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "skipped",
  "url": "https://open.spotify.com/episode/329nSfeZ5vRoJooKsZh9kH",
  "reason": "Audio-only Spotify episode (DRM protected) - skipped. This episode contains only audio which is Widevine-encrypted and cannot be downloaded."
}
FieldTypeDescription
typestringAlways "job_skipped"
job_idstringUUID of the job
statusstringAlways "skipped"
urlstringOriginal source URL
reasonstringExplanation of why the job was skipped
In batch downloads, skipped episodes count toward the batch failed counter for completion tracking purposes. The batch webhook fires once all episodes are either completed, failed, or skipped.

Batch Completion

When all episodes in a batch are done:
{
  "type": "batch_completed",
  "batch_id": "550e8400-e29b-41d4-a716-446655440001",
  "status": "completed",
  "show_url": "https://open.spotify.com/show/...",
  "folder": "my-podcast-2024",
  "total_episodes": 142,
  "completed_episodes": 140,
  "failed_episodes": 2
}
FieldTypeDescription
typestringAlways "batch_completed"
batch_idstringUUID of the batch
statusstring"completed" (all succeeded) or "finished" (some failed)
show_urlstringOriginal Spotify show URL
folderstring | nullFolder prefix if provided
total_episodesintegerTotal episodes in the batch
completed_episodesintegerSuccessfully completed episodes
failed_episodesintegerFailed/skipped episodes

Progress Webhooks

When enable_progress_webhook: true is set, you’ll receive updates at each processing stage:
{
  "type": "progress",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "url": "https://youtube.com/watch?v=...",
  "stage": "downloading",
  "progress_percent": 0
}
Progress stages and percentages:
StageProgressDescription
downloading0%Download started
download_complete33%Download finished
muxing33%Muxing audio/video started
mux_complete66%Muxing finished
uploading66%Upload to storage started
completed100%Job completed (standard webhook)
To enable progress webhooks:
curl -X POST "https://api.tornadoapi.io/jobs" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://youtube.com/watch?v=...",
    "webhook_url": "https://your-app.com/webhooks/tornado",
    "enable_progress_webhook": true
  }'

Webhook Behavior

AspectBehavior
MethodPOST
Content-Typeapplication/json
Timeout30 seconds
Retries3 attempts with exponential backoff

Handling Webhooks

Node.js (Express)

const express = require('express');
const app = express();

app.use(express.json());

app.post('/webhooks/tornado', (req, res) => {
  const { type, job_id, status } = req.body;

  if (type === 'job_completed') {
    const { key, title, description, release_date, subtitle_key } = req.body;
    console.log(`Job ${job_id} completed: ${key}`);
    console.log(`Title: ${title}, Release: ${release_date}`);
    // Download or process the file
  } else if (type === 'job_failed') {
    const { error } = req.body;
    console.log(`Job ${job_id} failed: ${error}`);
  } else if (type === 'job_skipped') {
    const { reason } = req.body;
    console.log(`Job ${job_id} skipped: ${reason}`);
  } else if (type === 'batch_completed') {
    const { batch_id, completed_episodes, failed_episodes, total_episodes } = req.body;
    console.log(`Batch ${batch_id}: ${completed_episodes}/${total_episodes} (${failed_episodes} failed)`);
  }

  res.status(200).send('OK');
});

app.listen(3000);

Python (Flask)

from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/tornado', methods=['POST'])
def handle_webhook():
    data = request.json

    if data['type'] == 'job_completed':
        print(f"Job {data['job_id']} completed: {data['key']}")
        print(f"Title: {data.get('title')}, Release: {data.get('release_date')}")

    elif data['type'] == 'job_failed':
        print(f"Job {data['job_id']} failed: {data['error']}")

    elif data['type'] == 'job_skipped':
        print(f"Job {data['job_id']} skipped: {data['reason']}")

    elif data['type'] == 'batch_completed':
        print(f"Batch {data['batch_id']}: {data['completed_episodes']}/{data['total_episodes']}")

    return 'OK', 200

if __name__ == '__main__':
    app.run(port=3000)

Best Practices

Return a 2xx status code within 30 seconds. Process the webhook data asynchronously if needed.
Webhooks come from our servers. Consider IP whitelisting or signature verification for production.
Handle duplicate webhooks gracefully. Store the job_id and check for duplicates before processing.
If your endpoint fails, we retry 3 times. Ensure your handler can process the same event multiple times.

Testing Webhooks

Use a service like webhook.site or ngrok to test webhooks locally:
# Start ngrok tunnel
ngrok http 3000

# Use the ngrok URL as your webhook_url
# https://abc123.ngrok.io/webhooks/tornado