# Render a video with webhooks

Start a render, have it call your server when it is done, and download the video, without polling.

A video takes from about twenty seconds to a few minutes to render. Rather than poll for it, give the job a `callback_url` and let it tell you when it is done.

## 1. Get your webhook secret

Deliveries are signed with your webhook secret, so your server can tell they came from us. Fetch it once and give it to the server that will receive them; it is not your API key, and that server needs no key.

```bash
# Your webhook signing secret (whsec_...), made on the first call
curl https://api.vultrinference.com/v1/webhooks/secret \
  -H "Authorization: Bearer $VULTR_INFERENCE_API_KEY"
```

## 2. Run a receiver

The receiver checks the signature, answers within 10 seconds, ignores a delivery it has already handled, and then does the work. It must be reachable at a public `https` address.

```python
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["VULTR_WEBHOOK_SECRET"]  # whsec_..., from GET /v1/webhooks/secret
handled = set()  # keep these in your database: a delivery can arrive more than once


def verified(header: str, body: bytes) -> bool:
    pairs = [part.split("=", 1) for part in header.split(",")]
    timestamp = next((value for key, value in pairs if key == "t"), "0")
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(SECRET.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, value) for key, value in pairs if key == "v1")


class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers["Content-Length"]))
        if not verified(self.headers.get("X-Vultr-Signature", ""), body):
            self.send_response(400)
            self.end_headers()
            return
        # Answer within 10 seconds, or the delivery is sent again: do slow work after this
        self.send_response(204)
        self.end_headers()
        key = self.headers["X-Vultr-Idempotency-Key"]
        if key in handled:
            return
        handled.add(key)
        event = json.loads(body)
        job = event["data"]
        if event["type"].endswith(".completed"):
            print(job["id"], "is ready:", job["unsigned_urls"][0])
        else:
            print(job["id"], "ended", job["status"], job.get("error"))


if __name__ == "__main__":
    HTTPServer(("", int(os.environ.get("PORT", "8000"))), Hook).serve_forever()
```

[Webhooks](/docs/concepts/webhooks) has the events, the headers, the retries and how to rotate the secret.

## 3. Start the render

`POST /v1/videos` checks the request and answers 202 at once with the job, `pending`. A field the model does not take is a 400 now, never a job that fails later.

```bash
# The job answers 202 at once; your callback_url gets the result when it is done
curl https://api.vultrinference.com/v1/videos \
  -H "Authorization: Bearer $VULTR_INFERENCE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "minimax-h3",
    "prompt": "A paper boat drifting down a rain-soaked street at dusk",
    "duration": 4,
    "callback_url": "https://example.com/hooks/video"
  }'
```

```python
import os

from openai import OpenAI

client = OpenAI(base_url="https://api.vultrinference.com/v1", api_key=os.environ["VULTR_INFERENCE_API_KEY"])

# The job answers at once; your callback_url gets the result when it is done
video = client.videos.create(
    model="minimax-h3",
    prompt="A paper boat drifting down a rain-soaked street at dusk",
    seconds="4",
    extra_body={"callback_url": "https://example.com/hooks/video"},
)
print(video.id, video.status)
```

What minimax-h3 takes, in short; [its page](/docs/models/minimax-h3) has everything:

| Field | Takes |
| --- | --- |
| `duration` | 4 to 15 seconds, 6 by default |
| `aspect_ratio` | `16:9`, `9:16`, `4:3`, `3:4`, `1:1` or `21:9` |
| `generate_audio` | `false` for a silent video; audio is made by default |

A job with only a prompt renders in about three seconds per second of video.

## 4. Handle the result

Your receiver gets one of four events:

| Event | Do |
| --- | --- |
| `video.generation.completed` | Download the video (below). `data.usage` has what it cost. |
| `video.generation.failed` | Read `data.error`, and decide by `data.error_code` (below). Not billed. |
| `video.generation.cancelled` | Nothing: the job was deleted before it finished. Not billed. |
| `video.generation.expired` | Nothing: the video was deleted 7 days after it completed. |

| `error_code` | Means | Do |
| --- | --- | --- |
| `provider_error` | The render failed or was interrupted on our side | Send the same request again |
| `timeout` | The render took too long | Send it again |
| `invalid_request` | An input could not be used, such as a media URL that would not download | Fix the request first |
| `payload_too_large` | A media input was over its size limit | Fix the request first |
| `server_error` | The job could not run, for example because its model was removed | Check the model, then send it again |

## 5. Download it

`GET /v1/videos/{id}/content` returns the MP4 itself; the job's `unsigned_urls` point there too. It needs your API key, and works until `expires_at`, 7 days after the job completed.

```bash
# The video, with your API key, until the job's expires_at (7 days after it completed)
curl -o video.mp4 "https://api.vultrinference.com/v1/videos/$VIDEO_ID/content?index=0" \
  -H "Authorization: Bearer $VULTR_INFERENCE_API_KEY"
```

## Billing

A job is billed only when it completes: the `duration` you asked for, times the model's price per second. A higher `quality` bills more seconds (`standard` 3x, `max` 5x), as does `resolution: 2K` (4x) or a raw `num_inference_steps` above the fast default; `usage.billed_seconds` shows the result. Failed and cancelled jobs cost nothing.
