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 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
POST /v1/formats/{format_id}/runswith the inputs you have. You get arun_idat once.- If something is missing, the run is
needs_inputandquestionslists what to answer. Answer them all in onePOST /v1/formats/runs/{run_id}/answers. Once the inputs are complete, the run is priced and asksconfirm_price. - After you approve, the run is
queued, thenrunning. PollGET /v1/formats/runs/{run_id}every 10 to 20 seconds (or pass awebhook_url). When it issucceeded,output.videosholds 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.
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
curl https://vidlab.ai/v1/formatsimport requests
for f in requests.get("https://vidlab.ai/v1/formats").json()["data"]:
print(f["id"], [i["name"] for i in f["inputs"]])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));Each format in data looks like this (shortened):
{
"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. |
Headers: Authorization (required) and Idempotency-Key (optional, see Idempotency).
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" } }'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()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();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
{
"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
{ "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'sproduct_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, unlessmax_price_usdalready covers the quote.
Answer the questions
POST /v1/formats/runs/{run_id}/answers with answers: question id to value.
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." } }'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()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();- Answer every required open question in one call. An answer to an input question becomes that input.
confirm_pricetakesapprove(start at the quoted price) orcancel(end the run, nothing charged).- The answer is the run: the next round of questions, or
queuedonce 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
curl https://vidlab.ai/v1/formats/runs/fmtrun_6f1c2a7d9b3e4f50a1c2d3e4f5a6b7c8 \
-H "Authorization: Bearer $VIDLAB_API_KEY"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", [])])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));A finished run's output:
"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, 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-centsandx-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:
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.
Errors
Every error uses the standard envelope.
| 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 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.