> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tornadoapi.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive notifications when jobs complete

## 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:

```bash 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://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:

### Job Started (Processing)

Sent when a worker picks up the job and begins processing:

```json theme={null}
{
  "type": "job_processing",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "processing",
  "url": "https://youtube.com/watch?v=...",
  "message": "Job Started by Worker"
}
```

| Field     | Type   | Description                   |
| --------- | ------ | ----------------------------- |
| `type`    | string | Always `"job_processing"`     |
| `job_id`  | string | UUID of the job               |
| `status`  | string | Always `"processing"`         |
| `url`     | string | Original source URL           |
| `message` | string | Human-readable status message |

### Single Job Completion

```json theme={null}
{
  "type": "job_completed",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "completed",
  "url": "https://youtube.com/watch?v=...",
  "key": "videos/my-video.mp4",
  "s3_url": "https://my-bucket.s3.amazonaws.com/videos/my-video.mp4?X-Amz-Signature=...",
  "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",
  "subtitle_url": "https://my-bucket.s3.amazonaws.com/videos/my-video.en.vtt?X-Amz-Signature=..."
}
```

| Field           | Type           | Description                                                                                                                                                                           |
| --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`          | string         | Always `"job_completed"`                                                                                                                                                              |
| `job_id`        | string         | UUID of the job                                                                                                                                                                       |
| `status`        | string         | `"completed"`                                                                                                                                                                         |
| `url`           | string         | Original source URL                                                                                                                                                                   |
| `key`           | string         | Storage key (path) of the uploaded file. Not downloadable on its own -- see `s3_url`                                                                                                  |
| `s3_url`        | string \| null | **Presigned, directly downloadable URL** for the video (24h validity). `null` if signing failed (rare) -- fall back to [`GET /jobs/{id}`](/api-reference/jobs/get-status) to fetch it |
| `folder`        | string \| null | Folder prefix if provided at job creation                                                                                                                                             |
| `title`         | string \| null | Video/episode title. Available for YouTube and Spotify                                                                                                                                |
| `description`   | string \| null | Content description from YouTube. `null` for Spotify                                                                                                                                  |
| `release_date`  | string \| null | ISO 8601 date. Spotify: exact publish date. YouTube: upload date (midnight UTC)                                                                                                       |
| `subtitle_key`  | string \| null | Storage key for subtitles (if `download_subtitles` was enabled)                                                                                                                       |
| `subtitle_url`  | string \| null | Presigned URL for subtitles. Only present when `subtitle_key` is                                                                                                                      |
| `thumbnail_key` | string \| null | Storage key for the thumbnail (if `download_thumbnail` was enabled)                                                                                                                   |
| `thumbnail_url` | string \| null | Presigned URL for the thumbnail. Only present when `thumbnail_key` is                                                                                                                 |

<Info>
  `s3_url`/`subtitle_url`/`thumbnail_url` are generated the same way (and with the same 24h validity) as the `s3_url` field returned by [`GET /jobs/{id}`](/api-reference/jobs/get-status) -- added 2026-07-08 so you don't need a second API call just to get something downloadable. `key`/`subtitle_key`/`thumbnail_key` are unchanged and still sent, for backwards compatibility.
</Info>

### 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.

```json theme={null}
{
  "type": "job_failed",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "failed",
  "url": "https://youtube.com/watch?v=...",
  "error": "Video unavailable"
}
```

| Field    | Type   | Description           |
| -------- | ------ | --------------------- |
| `type`   | string | Always `"job_failed"` |
| `job_id` | string | UUID of the job       |
| `status` | string | `"failed"`            |
| `url`    | string | Original source URL   |
| `error`  | string | Error 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.

```json theme={null}
{
  "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."
}
```

| Field    | Type   | Description                            |
| -------- | ------ | -------------------------------------- |
| `type`   | string | Always `"job_skipped"`                 |
| `job_id` | string | UUID of the job                        |
| `status` | string | Always `"skipped"`                     |
| `url`    | string | Original source URL                    |
| `reason` | string | Explanation of why the job was skipped |

<Note>
  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.
</Note>

### Batch Completion

When all episodes in a batch are done:

```json theme={null}
{
  "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
}
```

| Field                | Type           | Description                                                 |
| -------------------- | -------------- | ----------------------------------------------------------- |
| `type`               | string         | Always `"batch_completed"`                                  |
| `batch_id`           | string         | UUID of the batch                                           |
| `status`             | string         | `"completed"` (all succeeded) or `"finished"` (some failed) |
| `show_url`           | string         | Original Spotify show URL                                   |
| `folder`             | string \| null | Folder prefix if provided                                   |
| `total_episodes`     | integer        | Total episodes in the batch                                 |
| `completed_episodes` | integer        | Successfully completed episodes                             |
| `failed_episodes`    | integer        | Failed/skipped episodes                                     |

### Progress Webhooks

When `enable_progress_webhook: true` is set, you'll receive updates at each processing stage:

```json theme={null}
{
  "type": "progress",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "url": "https://youtube.com/watch?v=...",
  "stage": "downloading",
  "progress_percent": 0
}
```

Progress stages and percentages:

| Stage               | Progress | Description                      |
| ------------------- | -------- | -------------------------------- |
| `downloading`       | 0%       | Download started                 |
| `download_complete` | 33%      | Download finished                |
| `muxing`            | 33%      | Muxing audio/video started       |
| `mux_complete`      | 66%      | Muxing finished                  |
| `uploading`         | 66%      | Upload to storage started        |
| `completed`         | 100%     | Job completed (standard webhook) |

To enable progress webhooks:

```bash 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://youtube.com/watch?v=...",
    "webhook_url": "https://your-app.com/webhooks/tornado",
    "enable_progress_webhook": true
  }'
```

## Webhook Behavior

| Aspect       | Behavior                            |
| ------------ | ----------------------------------- |
| Method       | `POST`                              |
| Content-Type | `application/json`                  |
| Timeout      | 10 seconds                          |
| Retries      | 3 attempts with exponential backoff |

## Handling Webhooks

### Node.js (Express)

```javascript theme={null}
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_processing') {
    console.log(`Job ${job_id} started processing`);
  } else if (type === 'job_completed') {
    const { key, s3_url, title, description, release_date, subtitle_key, subtitle_url } = req.body;
    console.log(`Job ${job_id} completed: ${key}`);
    console.log(`Title: ${title}, Release: ${release_date}`);
    if (s3_url) {
      // Ready to fetch directly -- no extra API call needed
      // await fetch(s3_url) / download to your own storage / queue for processing
    }
  } 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)

```python theme={null}
from flask import Flask, request

app = Flask(__name__)

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

    if data['type'] == 'job_processing':
        print(f"Job {data['job_id']} started processing")

    elif data['type'] == 'job_completed':
        print(f"Job {data['job_id']} completed: {data['key']}")
        print(f"Title: {data.get('title')}, Release: {data.get('release_date')}")
        if data.get('s3_url'):
            # Ready to download directly -- no extra API call needed
            # requests.get(data['s3_url']) / stream to your own storage / queue for processing
            pass

    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

<AccordionGroup>
  <Accordion icon="clock" title="Respond Quickly">
    Return a 2xx status code within 30 seconds. Process the webhook data asynchronously if needed.
  </Accordion>

  <Accordion icon="shield" title="Verify Source">
    Webhooks come from our servers. Consider IP whitelisting or signature verification for production.
  </Accordion>

  <Accordion icon="database" title="Idempotency">
    Handle duplicate webhooks gracefully. Store the job\_id and check for duplicates before processing.
  </Accordion>

  <Accordion icon="rotate" title="Retry Logic">
    If your endpoint fails, we retry 3 times. Ensure your handler can process the same event multiple times.
  </Accordion>
</AccordionGroup>

## Testing Webhooks

Use a service like [webhook.site](https://webhook.site) or [ngrok](https://ngrok.com) to test webhooks locally:

```bash theme={null}
# Start ngrok tunnel
ngrok http 3000

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