# Formats

Run one of our ad formats end to end and get the finished video back. You pick a format, send what you have, and VidLab's own agent makes the whole ad with the method and models chosen for that format. If it needs something (a product photo, what the ad should say, your yes to the price) it asks, and your agent answers.

## Formats and model calls

A [generation](/vidlab-api/docs/submit-a-generation) runs one model on your input. A format run makes a finished ad: script, shots, voice and edit. You see the format's name, its inputs, the questions it asks, the price and the result. The method itself (scripts, prompts, shot logic, which models run each step) stays on our side.

## How a run works

1. `POST /v1/formats/{format_id}/runs` with the inputs you have. You get a `run_id` at once.
2. If something is missing, the run is `needs_input` and `questions` lists what to answer. Answer them all in one `POST /v1/formats/runs/{run_id}/answers`. Once the inputs are complete, the run is priced and asks `confirm_price`.
3. After you approve, the run is `queued`, then `running`. Poll `GET /v1/formats/runs/{run_id}` every 10 to 20 seconds (or pass a `webhook_url`). When it is `succeeded`, `output.videos` holds the videos.

Nothing is charged before you approve the price. Send `max_price_usd` with the run to approve in advance: a quote at or below it starts without the price question.

## Endpoints

| Method | Path | What it does | Key |
| - | - | - | - |
| `GET` | `/v1/formats` | Lists the formats you can run. | No |
| `GET` | `/v1/formats/{format_id}` | One format: its inputs, questions and price rule. | No |
| `POST` | `/v1/formats/{format_id}/runs` | Starts a run. | Yes |
| `GET` | `/v1/formats/runs/{run_id}` | Reads a run. | Yes |
| `POST` | `/v1/formats/runs/{run_id}/answers` | Answers the open questions. | Yes |
| `POST` | `/v1/formats/runs/{run_id}/cancel` | Stops a run. | Yes |

Dashboard keys have full access. A scoped key needs the `runs` scope (reading a run also accepts `tools` or `jobs`). Reads are free of request limits; starts and answers count against your [rate limits](/vidlab-api/docs/rate-limits).

## The formats at launch

| id | Name | What it makes | Must send or answer |
| - | - | - | - |
| `talking-head-ugc` | Talking Head UGC | A creator talks to camera about your product, shot like a phone video. | What it is about: `brief`, `script`, `product_image` or `product_name` |
| `street-interview` | Street interview | A two-person street interview about your product, in one take. | What it is about: `brief`, `product_image` or `product_name` |
| `drama-ad` | Drama ad | A short acted drama with a twist, where your product turns the story. | What it is about: `brief`, `product_image` or `product_name` |
| `app-demo` | App demo | Your app's screen recording with a creator reacting to it. | `screen_recording` |
| `recipe-montage` | Recipe montage | A cooking montage of a dish made with your product, with real kitchen sound. | `product_image` |
| `product-commercial` | Product commercial | Cinematic hero shots of your product, cut to music. | `product_image` |

`GET /v1/formats` is the live list: read it rather than hard-coding inputs.

## List formats

```bash cURL
curl https://vidlab.ai/v1/formats
```

```js Node
const { data: formats } = await (await fetch("https://vidlab.ai/v1/formats")).json();
for (const f of formats) console.log(f.id, f.inputs.map((i) => i.name));
```

```python Python
import requests

for f in requests.get("https://vidlab.ai/v1/formats").json()["data"]:
    print(f["id"], [i["name"] for i in f["inputs"]])
```

Each format in `data` looks like this (shortened):

```json
{
  "object": "format",
  "id": "talking-head-ugc",
  "name": "Talking Head UGC",
  "description": "A creator talks to camera about your product, shot like a phone video.",
  "kind": "video",
  "minutes": 6,
  "inputs": [
    { "name": "product_image", "type": "image", "required": false, "description": "A clear photo of the product." },
    { "name": "brief", "type": "text", "required": false, "description": "What the ad should say, in a sentence or two.", "max_length": 2000 },
    { "name": "length_seconds", "type": "choice", "required": false, "options": ["15", "30", "45", "60"], "default": "30" },
    { "name": "hooks", "type": "choice", "required": false, "description": "How many opening hooks to make, one video each.", "options": ["1", "3", "5"], "default": "3" }
  ],
  "price": { "rule": "quoted_per_run", "from_usd": 7.07, "note": "Every run is quoted for its own inputs before it starts." },
  "questions_it_may_ask": [
    { "id": "brief", "text": "What is the ad about? Name the product and the one thing the creator should say.", "type": "text", "required": true },
    { "id": "confirm_price", "text": "This video costs $X. Start it?", "type": "choice", "options": ["approve", "cancel"], "required": true }
  ]
}
```

`price.from_usd` is the price of the format's default inputs, read live from the API. A run's own price depends on its inputs (length, number of hooks).

### Input types

Files go in as **public URLs only**. VidLab downloads each one when you send it, so a URL that cannot be read is refused at once (`url_unreadable`).

| type | Send |
| - | - |
| `text` | A string, up to `max_length` characters. |
| `choice` | One of `options`, as a string (`"30"`; a number is read as its string). |
| `image` | A public `https` image URL, 30 MB at most. |
| `video` | A public `https` video URL (MP4 or MOV), 300 MB at most. |
| `url` | A public `http` or `https` URL. |

Leave out anything you do not have: the run asks for what it needs. An input the format does not take is refused with `unknown_input`, and the message lists its inputs.

## Start a run

`POST /v1/formats/{format_id}/runs`

| Field | Type | Required | What it is |
| - | - | - | - |
| `inputs` | object | No | Input name to value, as `GET /v1/formats` lists them. |
| `max_price_usd` | number | No | The most you approve in advance, 0 to 10000. A quote at or below it starts without the price question. |
| `webhook_url` | string | No | An `https` URL VidLab POSTs to when the run needs answers or ends. See [Webhooks](#webhooks). |

Headers: `Authorization` (required) and `Idempotency-Key` (optional, see [Idempotency](#idempotency)).

```bash cURL
curl -X POST https://vidlab.ai/v1/formats/talking-head-ugc/runs \
  -H "Authorization: Bearer $VIDLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-talking-head" \
  -d '{ "inputs": { "length_seconds": "15", "hooks": "1" } }'
```

```js Node
const res = await fetch("https://vidlab.ai/v1/formats/talking-head-ugc/runs", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.VIDLAB_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "order-1042-talking-head",
  },
  body: JSON.stringify({ inputs: { length_seconds: "15", hooks: "1" } }),
});
let run = await res.json();
```

```python Python
import os, requests

H = {"Authorization": f"Bearer {os.environ['VIDLAB_API_KEY']}"}
run = requests.post(
    "https://vidlab.ai/v1/formats/talking-head-ugc/runs",
    headers={**H, "Idempotency-Key": "order-1042-talking-head"},
    json={"inputs": {"length_seconds": "15", "hooks": "1"}},
).json()
```

The answer is the run: `202` while it is open (`needs_input`, `queued`, `running`), `200` when it is already over (a replay of a finished run).

## The run object

```json
{
  "object": "format_run",
  "run_id": "fmtrun_6f1c2a7d9b3e4f50a1c2d3e4f5a6b7c8",
  "format": { "id": "talking-head-ugc", "name": "Talking Head UGC" },
  "status": "needs_input",
  "questions": [
    { "id": "brief", "text": "What is the ad about? Name the product and the one thing the creator should say.", "type": "text", "required": true }
  ],
  "inputs": { "length_seconds": "15", "hooks": "1" },
  "price": { "quoted_usd": null, "max_price_usd": null, "charged_usd": null },
  "progress": null,
  "output": null,
  "error": null,
  "next_action": "Answer the questions with POST answers_url.",
  "created_at": "2026-11-01T18:20:04.112Z",
  "updated_at": "2026-11-01T18:20:04.131Z",
  "completed_at": null,
  "status_url": "/v1/formats/runs/fmtrun_6f1c2a7d9b3e4f50a1c2d3e4f5a6b7c8",
  "answers_url": "/v1/formats/runs/fmtrun_6f1c2a7d9b3e4f50a1c2d3e4f5a6b7c8/answers",
  "cancel_url": "/v1/formats/runs/fmtrun_6f1c2a7d9b3e4f50a1c2d3e4f5a6b7c8/cancel"
}
```

| Field | What it is |
| - | - |
| `run_id` | `fmtrun_` and 32 hex characters. |
| `status` | `needs_input`, `queued`, `running`, `succeeded`, `failed` or `cancelled`. |
| `questions` | The open questions, only while `needs_input`. |
| `inputs` | Everything sent so far, answers included. |
| `price.quoted_usd` | The quote for these inputs, once they are complete. |
| `price.max_price_usd` | The most you approved. |
| `price.charged_usd` | What the run charged, once it is over (0 when it never started). |
| `progress` | While `queued` or `running`: `{ "stage", "done", "total" }`. |
| `output` | When `succeeded`: `{ "videos": [...], "duration_seconds" }`. |
| `error` | `{ code, message }` when the run failed or was closed, or an approved start could not go ahead yet. |
| `next_action` | One line on what to do next. |

## Statuses

| Status | Meaning | What to do |
| - | - | - |
| `needs_input` | Waiting for your answers. Nothing is held or charged. | Answer every required question in one call. |
| `queued` | Approved and waiting to start. The quote is held. | Poll every 10 to 20 seconds. |
| `running` | Being made. `progress.stage` names the step. | Poll every 10 to 20 seconds. |
| `succeeded` | Done. `output.videos` holds the videos. | Download them. |
| `failed` | It could not be finished. What ran is charged at cost; the rest of the hold comes back. | Read `error`, then start a new run if you still need it. |
| `cancelled` | You cancelled it, answered `cancel` to the price, or did not answer within 24 hours. | Nothing more happens. |

A run left in `needs_input` for 24 hours is closed as `cancelled` with `error.code` `questions_expired`. Nothing was charged.

## Questions

```json
{ "id": "confirm_price", "text": "This video costs $X. Start it?", "type": "choice", "options": ["approve", "cancel"], "required": true }
```

| Field | What it is |
| - | - |
| `id` | What you answer under. For an input question it is the input's name (`brief`, `screen_recording`, `product_name`). |
| `text` | The question in plain words. Show it to your user or answer it from what you know. |
| `type` | `text`, `choice` (one of `options`) or `file` (a public URL of the input's kind). |
| `required` | Every required question must be answered in the same call. |

A run can ask for:

- a required input you left out (an App demo's `screen_recording`, a Product commercial's `product_image`);
- what the video is about, when you sent none of the format's starting points;
- `product_name`, when you sent a product photo without a name;
- `confirm_price`, once the inputs are complete, unless `max_price_usd` already covers the quote.

## Answer the questions

`POST /v1/formats/runs/{run_id}/answers` with `answers`: question id to value.

```bash cURL
curl -X POST https://vidlab.ai/v1/formats/runs/fmtrun_6f1c2a7d9b3e4f50a1c2d3e4f5a6b7c8/answers \
  -H "Authorization: Bearer $VIDLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "answers": { "brief": "Sleep gummies: asleep in 20 minutes, no grogginess." } }'
```

```js Node
run = await (
  await fetch(`https://vidlab.ai/v1/formats/runs/${run.run_id}/answers`, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.VIDLAB_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ answers: { brief: "Sleep gummies: asleep in 20 minutes, no grogginess." } }),
  })
).json();
```

```python Python
run = requests.post(
    f"https://vidlab.ai/v1/formats/runs/{run['run_id']}/answers",
    headers=H,
    json={"answers": {"brief": "Sleep gummies: asleep in 20 minutes, no grogginess."}},
).json()
```

- Answer every required open question in one call. An answer to an input question becomes that input.
- `confirm_price` takes `approve` (start at the quoted price) or `cancel` (end the run, nothing charged).
- The answer is the run: the next round of questions, or `queued` once it starts.
- Sending the same answers again returns the run as it stands. Other answers on a run that is not waiting are refused with `409 not_waiting_for_input`.

### When an approved start cannot go ahead yet

If you approve but the start is refused for a reason that can pass, the run stays `needs_input` with `confirm_price` open, and `error` says why:

| `error.code` | Why | Fix |
| - | - | - |
| `api_wallet_exhausted` | The API wallet cannot cover the quote. | Add credit, then answer `approve` again. |
| `api_key_budget_exhausted` | The key's monthly budget is used. | Use another key, or wait for the next cycle. |
| `too_many_active_runs` | Three format runs are already in progress on this account. | Answer `approve` again when one finishes. |

## Read a run

```bash cURL
curl https://vidlab.ai/v1/formats/runs/fmtrun_6f1c2a7d9b3e4f50a1c2d3e4f5a6b7c8 \
  -H "Authorization: Bearer $VIDLAB_API_KEY"
```

```js Node
while (run.status === "queued" || run.status === "running") {
  await new Promise((r) => setTimeout(r, 15000));
  run = await (await fetch(`https://vidlab.ai/v1/formats/runs/${run.run_id}`, { headers: { Authorization: `Bearer ${process.env.VIDLAB_API_KEY}` } })).json();
}
console.log(run.status, run.output?.videos.map((v) => v.url));
```

```python Python
import time

while run["status"] in ("queued", "running"):
    time.sleep(15)
    run = requests.get(f"https://vidlab.ai/v1/formats/runs/{run['run_id']}", headers=H).json()
print(run["status"], [v["url"] for v in (run.get("output") or {}).get("videos", [])])
```

A finished run's `output`:

```json
"output": {
  "videos": [
    {
      "kind": "video",
      "url": "https://...",
      "mime_type": "video/mp4",
      "duration_seconds": 15.2,
      "label": "Hook A",
      "expires_at": "2026-11-02T00:31:10.000Z"
    }
  ],
  "duration_seconds": 15.2
}
```

Video links last 6 hours; read the run again for fresh ones. A format with several hooks returns one video per hook.

## Cancel a run

`POST /v1/formats/runs/{run_id}/cancel`. A run still asking ends at once with nothing charged. A started run stops: what already ran is charged at cost and the rest of the hold comes back. Safe to repeat.

## Billing

- A run pays from the prepaid [API wallet](/vidlab-api/docs/billing), on REST with a `vidlab_sk_` key and on MCP with a key or OAuth. When you approve, the quote is held. When the run ends it is charged what it actually cost, never more than the approved quote, and the rest goes back to the wallet, once.
- A failed or cancelled run pays only for what ran.
- Answers that start billable work carry `x-wallet-remaining-cents` and `x-wallet-resets-at`.

## Idempotency

Send `Idempotency-Key` (1 to 128 characters of `A-Z a-z 0-9 _ . : -`) on `POST /v1/formats/{format_id}/runs`. For the rest of the UTC day, the same key with the same request returns the original run (with the header `idempotent-replayed: true`), never a second run or charge. The same key with a different request is refused with `409 idempotency_conflict`.

## Webhooks

Pass `webhook_url` (https) when you start a run. VidLab POSTs to it when the run needs answers and when it ends:

```text
POST https://example.com/vidlab-hook
Content-Type: application/json
X-Vidlab-Event: format_run.succeeded

{ "event": "format_run.succeeded", "run": { "object": "format_run", "run_id": "fmtrun_...", "status": "succeeded" } }
```

Events: `format_run.needs_input`, `format_run.succeeded`, `format_run.failed`, `format_run.cancelled`.

> Webhooks are **unsigned and best effort**: each is sent once, with a 5 second timeout, no redirects and no retry. Treat one as a nudge, and read the run with your key before acting on it.

## Errors

Every error uses the standard [envelope](/vidlab-api/docs/errors).

| Status | Code | When |
| - | - | - |
| 400 | `invalid_json` | The body is not a JSON object. |
| 400 | `unknown_field` | A body field other than `inputs`, `max_price_usd`, `webhook_url` (or `answers`). |
| 400 | `unknown_input` | An input the format does not take. |
| 400 | `invalid_input` | A value of the wrong type, a choice not in `options`, or a URL that is not http or https. |
| 400 | `invalid_inputs` | Inputs that cannot make this video together. |
| 400 | `url_unreadable` | A file URL could not be downloaded (not public, too large, unreachable). |
| 400 | `invalid_webhook_url` | `webhook_url` is not an https URL. |
| 400 | `invalid_max_price` | `max_price_usd` is not a number from 0 to 10000. |
| 400 | `invalid_idempotency_key` | The key is not 1 to 128 allowed characters. |
| 400 | `invalid_answers`, `unknown_question`, `invalid_answer`, `answers_incomplete` | The answers do not match the open questions. |
| 400 | `invalid_run_id` | Not a `fmtrun_` id. |
| 401 | `unauthorized` | No key, or a revoked one. |
| 403 | `api_key_scope_forbidden` | The key's scopes do not include `runs`. |
| 404 | `format_not_found` | No such format, or not available right now. |
| 404 | `run_not_found` | No run with that id on this account. |
| 409 | `idempotency_conflict` | The Idempotency-Key was used for a different request. |
| 409 | `not_waiting_for_input` | Answers sent to a run that is not `needs_input`. |
| 409 | `answer_in_flight` | Two answer calls at once: read the run. |
| 413 | `storage_full` | A file could not be stored: the account's storage is full. |
| 429 | `rate_limited` and the account limits | Too many requests. Wait for `Retry-After`. |
| 503 | `api_wallet_reconciliation_required` | The wallet is on a billing review hold. Contact support. |

## MCP

The [MCP endpoint](/vidlab-api/docs/mcp) has the same flow as five tools, with the same answers (a run names the tool to call next instead of a URL):

| Tool | What it does |
| - | - |
| `list_formats` | The formats, or one with `format_id`. Free. |
| `run_format` | Starts a run: `format_id`, `inputs`, optional `max_price_usd`, `webhook_url`, `idempotency_key`. |
| `answer_format_questions` | `run_id` and `answers`. |
| `get_format_run` | Reads a run. Free. |
| `cancel_format_run` | Stops a run that has not finished. Free. |

On MCP, to use a local image, stage it with `stage_file` and pass its `upload_id` as the image input. Connect with `?profile=api` to get just these tools and the model tools.
