> This is the Markdown version of the Azimo developer docs page "Advanced usage", for use by an AI assistant.
> - Azimo (https://azimo.ai) is an AI business-video production service: a brief and a few files in, a QA-checked commercial video out. It offers an asynchronous REST API, Python and TypeScript SDKs, the azimo command line and an MCP server.
> - Base URL: https://azimo.ai
> - Auth: every /v1 request sends `Authorization: Bearer <API key>` (create a key at https://azimo.ai/console#account); `X-Project-ID` selects a project, the default project otherwise.
> - This page: https://azimo.ai/en/developers/advanced
> - Prices are not published: contact sales@azimo.ai.

# Advanced usage

What to know before you go to production: webhooks, safe retries, errors, rate limits, projects and roles, budget approval, revisions and deleting files.

Every endpoint and field is listed in the [API reference](https://azimo.ai/en/developers/api); this page is about how to use them.

## Receive results with webhooks

Instead of polling, register a URL. Whenever a video's status changes, Azimo POSTs an event to it. You need a key with the `owner` role:

```bash
curl -s https://azimo.ai/v1/webhooks \
  -H "Authorization: Bearer $AZIMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: webhook-001" \
  -d '{"url": "https://example.com/hooks/azimo", "name": "Production updates"}'
```

The response includes a `signing_secret` (it starts with `whsec_`). It is shown only this once, so store it.

An event looks like this. Its `type` is `video.` plus the status, such as `video.progress`, `video.needs_input`, `video.complete`, `video.rejected`, `video.failed` or `video.cancelled`:

```json
{"id": "d8009f54780243ea", "sequence": 12, "type": "video.complete", "api_version": "v1", "project_id": "c56eb945e4464aec", "created_at": 1790000000, "data": {"id": "b6bf4689035d474f", "status": "complete", "stage": "complete", "progress": 100}}
```

Each delivery carries two headers: `Vidgen-Event-Id` (the event id) and `Vidgen-Signature: t=<timestamp>,v1=<signature>`. The signature is the hex `HMAC-SHA256(signing_secret, "<timestamp>." + raw body)`. Always verify the raw bytes you received; do not parse and re-serialize the JSON first:

```python
import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:  # reject anything older than 5 minutes
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

The Python SDK ships the same check as `Vidgen.verify_webhook(secret, header, body)`; in the TypeScript SDK it is `Vidgen.verifyWebhook(secret, header, body)`.

When an event arrives:

- Once the signature checks out, return a 2xx quickly and do slow work in your own queue.
- Failed deliveries are retried for up to about 72 hours, so the same event can arrive more than once: **de-duplicate** by event `id`.
- Events can arrive out of order. When in doubt, call `GET /v1/videos/{id}` for the current state.
- New event types may be added; ignore any `type` you do not recognise.
- Download links expire; fetch the video again when you need fresh ones.

You can also set a callback for a single video: add `webhook_url` to `POST /v1/videos`, and use the `webhook_signing_secret` in the response to verify it. If you would rather not run a webhook at all, read events in order with `GET /v1/events?after=<sequence>`.

## Retry safely with Idempotency-Key

Every POST that creates or changes something (uploads, plans, productions, revisions, cancels, answers, webhooks and so on) accepts an `Idempotency-Key` header of 1 to 128 characters.

- Same key, same body: you get the first result back and nothing is done twice.
- Same key, different body: `409` with the code `idempotency_conflict`.
- New key: a new request. Submitting the same plan with a new key makes another video.

In practice: generate the key before sending, store it with your own record, and reuse it when you retry after a timeout or a dropped connection.

## Handle errors

Every error has the same shape:

```json
{"error": {"code": "validation_error", "message": "request validation failed", "details": [{"loc": ["body", "options", "duration_max"], "msg": "Input should be less than or equal to 180", "type": "less_than_equal"}], "request_id": "64ceb19e540b438f8b9a4df3bfe62ba7"}}
```

Branch on `code`, never on the wording of `message`. Every response carries an `X-Request-ID` header; include the `request_id` when you contact us.

| HTTP | `code` | What to do |
| --- | --- | --- |
| 401 | `unauthenticated` | The key is missing, wrong or revoked; use a valid one |
| 402 | `insufficient_balance` / `budget_exceeded` | Not enough balance, or a spending limit was reached; top up or contact sales@azimo.ai rather than retrying |
| 403 | `forbidden` | Your role or project does not allow it, for example `read-only key` |
| 403 | `contact_sales` | Anything about pricing; contact sales@azimo.ai |
| 404 | `not_found` | The id does not exist, or is not in the current project |
| 409 | `conflict` / `idempotency_conflict` | The state does not allow it (the video has already finished, say), or the key was used for a different request |
| 413 | `payload_too_large` / `storage_quota_exceeded` | The file is over 60 MB, or your storage is full; delete unused assets |
| 415 | `unsupported_media_type` | That file type is not accepted |
| 422 | `validation_error` | A field is wrong; `details` says which |
| 429 | `rate_limited` | Too many requests; wait for `Retry-After` and try again |
| 500 | `internal_error` | Something broke on our side; contact us with the `request_id` |
| 503 | `backend_unconfigured` | This workflow is not available right now |

## Rate limits and 429

- By default each key can make up to 600 API requests per minute.
- Each organization can start a limited number of productions per hour, and a key can have its own, lower limit.
- Going over returns `429` with a `Retry-After` header giving the seconds to wait.

The Python SDK automatically retries 429 and 5xx responses for GETs and for POSTs that carry an `Idempotency-Key`, honouring `Retry-After`. When polling a video, once every 10 seconds or so is enough.

## Projects and key roles

Assets, plans, videos and webhooks all belong to a project. Choose one with the `X-Project-ID` header; without it you use your default project. An organization owner can create projects with `POST /v1/projects` and keys with `POST /v1/api-keys`, setting each key's `role` and optional `project_id`.

| Role | What it can do |
| --- | --- |
| `viewer` | Read only: videos, plans, assets, events and balance |
| `editor` | Also upload, create plans, start production, cancel, answer and revise |
| `owner` (bound to a project) | Also manage that project's webhooks |
| `owner` (not bound) | Organization owner: manages all projects and keys |

Give each app or client its own key, with the smallest role that does the job.

## Budget approval

If a video would go past its spending limit during production, it pauses at `needs_input` and `result.input_required` reads:

```json
{"code": "budget_approval", "actor": "customer", "message": "…", "approvable": true}
```

To let it continue, answer:

```bash
curl -s https://azimo.ai/v1/videos/b6bf4689035d474f/resume \
  -H "Authorization: Bearer $AZIMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: approve-b6bf-001" \
  -d '{"approve_budget": true}'
```

In the SDK that is `azimo.approve_budget(video_id)`; on the command line, `azimo resume ID --approve-budget`. Your balance still has to cover the rest, otherwise you get a `402`. Steps that are already done are not redone.

Answer any other `needs_input` in words with `{"message": "..."}`, adding `"assets": ["<asset id>"]` when it asks for more material. A video left unanswered for too long (7 days by default) is cancelled automatically.

## Revisions

You can revise a video that has ended (`complete`, `rejected`, `failed`, `cancelled` or `needs_input`):

```bash
curl -s https://azimo.ai/v1/videos/b6bf4689035d474f/revisions \
  -H "Authorization: Bearer $AZIMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: revise-b6bf-001" \
  -d '{"prompt": "The full new brief: same product, but end on the two-cup capacity.", "options": {"subtitles": true}}'
```

- You get a new video whose `parent_id` points to the original; the original stays as it is.
- `prompt` replaces the original brief entirely, so write it in full. `options` changes only the fields you send. Leave out `assets` to keep the original material; if you have deleted it, you get a `404`, so upload it again and pass `assets`.
- A revision is a complete new production and uses your balance. While the original is still in production you get a `409`.

## Cancel and delete

- Cancel: `POST /v1/videos/{id}/cancel`. Queued or waiting videos stop at once; one in production stops before its next step. A cancelled video is charged for what it had already used.
- No charge for failed QA: a video that ends `rejected` (it did not pass our quality checks) or `failed` (we could not make it) is not charged. Its whole hold is returned, and your wallet entries show a zero-amount `settle` saying why.
- Delete an upload: `DELETE /v1/assets/{id}`. Videos already made from it are not affected, but a later revision of such a video needs the material again.
- Delete a video's files: `DELETE /v1/videos/{id}`, only once the video has ended (cancel it first). The film, subtitles and cover are removed and the download links stop working; the video record stays.
- Brands, templates and webhooks can be deleted with `DELETE` too.
- Finished films are kept for a limited time, so download and store them promptly.
