Reference
REST API and webhooks
The REST API is a small HTTP interface under /v1 for scripts and backends: price a clip, generate it, read it and download the file, and run clip batches. It uses the same API token as MCP, and the same limits apply to it.
The basics
- The base path is
/v1on the studio host, the same host as the MCP server. In the examplesSTUDIO_URLholds the address andMCP_API_TOKENholds the token. - Authentication is a bearer token only:
Authorization: Bearer <token>. A web sign-in cookie is never read. A call without a token fails withUNAUTHENTICATED(HTTP 401). - Create the token on the Developers page, as in the quickstart. Its mode, allowed tools, project scope, calls per minute, spend cap, paid jobs at once and suspension all apply exactly as they do over MCP.
- An endpoint needs the MCP tool of the same purpose in the token’s allowed tools (see the table below).
GET /v1/meneeds none.
export STUDIO_URL="https://<your-studio-host>"
export MCP_API_TOKEN="<your token>"
curl -sS "$STUDIO_URL/v1/me" -H "Authorization: Bearer $MCP_API_TOKEN"GET /v1/me{
"object": "token",
"workspace_id": "<workspace id>",
"project_id": null,
"mode": "execute",
"environment": "<environment>",
"tools": ["quote_media_task", "generate_media_task", "get_media_task"],
"rate_limit_per_minute": 60
}Endpoints
| Endpoint | Needs the tool | What it does |
|---|---|---|
GET /v1/me | none | The token’s workspace, project, mode, allowed tools and calls per minute. |
GET /v1/models | list_video_models | The video models you can select, and default_video_model. |
POST /v1/media-tasks/quotes | quote_media_task | Quotes a media task and answers 201. A quote is free. |
POST /v1/media-tasks/{task_id}/generate | generate_media_task | Starts a quoted task. It spends at the quoted price at once. |
GET /v1/media-tasks | get_media_task | Lists tasks. kind defaults to video_clip. |
GET /v1/media-tasks/{task_id} | get_media_task | Reads one task. |
GET /v1/media-tasks/{task_id}/file | get_media_task | Downloads the file. It answers 409 until the task is READY. |
POST /v1/media-batches | create_media_batch | Creates a clip batch and, by default, starts it. Answers 201. |
GET /v1/media-batches/{batch_id} | get_media_batch | Reads a batch: status, counts and progress. |
GET /v1/media-batches/{batch_id}/items | list_media_batch_items | Lists the items. An item that is ready has a file_url. |
GET /v1/openapi.json, GET /v1/docs | none | The OpenAPI document and a page that lists the endpoints. Both are open: no token. |
The quote body takes the same fields as the MCP tool quote_media_task; unknown fields are refused. The fields for a video clip are kind, prompt, duration_seconds, ratio, resolution, route and reference_asset_id. The other kinds are voice, music, sfx and image. The workspace is never part of a body: a token belongs to one workspace.
Price, then generate a clip
- Quote the clip. The reply has the task
idandstateQUOTED. - Show the price to the person. Only after they agree, generate. The reply has
stateQUEUED. - Read the task until
stateisREADY, or wait for a webhook (below). - Download the file.
curl -sS -X POST "$STUDIO_URL/v1/media-tasks/quotes" \
-H "Authorization: Bearer $MCP_API_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"kind": "video_clip", "prompt": "A ceramic mug on a bright table, slow turn", "duration_seconds": 4, "ratio": "9:16"}'export TASK_ID="<id from the quote>"
curl -sS -X POST "$STUDIO_URL/v1/media-tasks/$TASK_ID/generate" \
-H "Authorization: Bearer $MCP_API_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"curl -sS "$STUDIO_URL/v1/media-tasks/$TASK_ID" -H "Authorization: Bearer $MCP_API_TOKEN"
curl -sS -o clip.mp4 "$STUDIO_URL/v1/media-tasks/$TASK_ID/file" -H "Authorization: Bearer $MCP_API_TOKEN"Batches
Send the table as csv (text), xlsx_base64 (a base64 .xlsx file) or rows (a list of objects). Besides the table the body takes these fields; unknown fields are refused.
| Field | Meaning |
|---|---|
csv, xlsx_base64, rows | The table. csv up to 2,000,000 characters, xlsx_base64 up to 4,000,000. A batch has 100 rows by default. |
template | Up to 3600 characters, with {{column}} placeholders. Without it each row’s prompt column is the prompt. |
route, duration_seconds, ratio, resolution, reference_asset_id | The clip options for every row; a column in the table replaces them for one row. See Video clips and clip batches. |
project_id | Optional project to attach the batch to. |
start | The default is true: the batch starts at once. false makes a free draft that a person starts in the web app. |
curl -sS -X POST "$STUDIO_URL/v1/media-batches" \
-H "Authorization: Bearer $MCP_API_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"rows": [{"product": "Ceramic mug"}, {"product": "Linen shirt"}], "template": "Slow turntable shot of a {{product}} on a bright table", "ratio": "9:16", "start": false}'The reply has the batch id, its status (draft here) and item_count. Read the batch with GET /v1/media-batches/{batch_id} and its items with GET /v1/media-batches/{batch_id}/items, optionally with state=ready.
Idempotency-Key
- Every
POSTneeds anIdempotency-Keyheader of 8 to 200 printable ASCII characters. A UUID is a good key. Without a valid one the call fails withV1_IDEMPOTENCY_KEY_REQUIRED(HTTP 400). - The key is scoped to your workspace and to the route. Repeating a key on the same route returns the first result and makes no second quote, task or batch. The same key on another route is a different request.
- Make a new key for each new request; reuse a key only to retry exactly the same call after a timeout.
Rate limit, errors and pagination
- Every answer carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset. The reset value is a unix time in seconds, when the current minute window ends. A 429 answer also carriesRetry-After. - A failed call returns
{code, message, retryable, request_id}. Themessagefollows theAccept-Languageheader of the request. Quote therequest_idwhen you ask for help. - The codes are the ones in the error reference; the REST-only ones are
V1_IDEMPOTENCY_KEY_REQUIREDandV1_CURSOR_INVALID. - Lists return
{object: "list", data, has_more, next_cursor, total}. Passnext_cursorback unchanged ascursorto get the next page.limitdefaults to 20 and is at most 100. A cursor that is not one of ours fails withV1_CURSOR_INVALID(HTTP 400).
{
"code": "V1_IDEMPOTENCY_KEY_REQUIRED",
"message": "<text in the language of Accept-Language>",
"retryable": false,
"request_id": "<request id>"
}OpenAPI document
GET /v1/openapi.json returns the OpenAPI document of the public API and GET /v1/docs shows the endpoints as a page. Neither needs a token, and both may be cached for a few minutes. The public contract is this document; the routes the web app itself uses are not part of it.
curl -sS "$STUDIO_URL/v1/openapi.json"Webhooks
A webhook sends a signed HTTPS POST to your server when something finishes, so a script does not have to poll.
- A workspace admin signs in to the web app, opens Developers and then Webhooks. An API token or an MCP session cannot manage webhooks: it gets
FORBIDDEN. There is no REST endpoint for it. - Add the endpoint URL, an optional description of up to 120 characters, and the events to send, or all of them. A workspace has at most 10 endpoints (
WEBHOOK_LIMIT_REACHED, HTTP 409). - Copy the signing secret, which starts with
whsec_. It is shown once, when you create the endpoint or rotate the secret. Afterwards only its last 4 characters are shown. - Choose Send test to receive a
webhook.testevent, usually within about 30 seconds. Test and replay sends are rate limited: too many waiting at once fails withWEBHOOK_RATE_LIMITED(HTTP 429, retryable); wait a few seconds and try again.
- Rotate secret: the previous secret keeps signing for 24 hours, so you can switch without losing events. During that time the signature header carries one
v1=value per live secret. - An endpoint can be disabled and deleted. A disabled endpoint receives nothing (
WEBHOOK_ENDPOINT_DISABLED, HTTP 409, when you try to send it a test). - The delivery log shows every attempt. Replay sends a delivery again with the same event id, so your server can recognise a duplicate.
Events and request headers
| Event | Sent when | Fields in `data` |
|---|---|---|
media_task.succeeded | A media task, such as a clip, is ready. | task_id, kind, state, billed_micros, currency, error_code, batch_id, row_index, url |
media_task.failed | A media task failed. | The same fields. |
batch.completed | A clip batch finished. | batch_id, status, ready, needs_attention, cancelled, url |
delivery.issued | A delivery was issued. | delivery_id, project_id |
webhook.test | You chose Send test. It cannot be subscribed to. |
created is a unix time in seconds){
"api_version": "v1",
"created": 1700000000,
"data": {
"batch_id": "<batch id>",
"cancelled": 0,
"needs_attention": 1,
"ready": 9,
"status": "completed",
"url": "/v1/media-batches/<batch id>"
},
"id": "<event id>",
"object": "event",
"type": "batch.completed",
"workspace_id": "<workspace id>"
}| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | RPT-Webhooks/1 |
RPT-Signature | t=<unix seconds>,v1=<hex> |
RPT-Event-Id | The event id. A replay keeps it. |
RPT-Event-Type | The event type. |
RPT-Delivery-Id | This delivery. |
RPT-Delivery-Attempt | The attempt number, starting at 1. |
Verify the signature
The signature is the hex HMAC-SHA256 of {t}.{raw body}, keyed with the secret. Always verify before you trust a request:
- Use the raw bytes of the body, exactly as received. Do not parse and re-serialize it first.
- Compare in constant time. Accept the request if any
v1value matches. - Reject a timestamp that is more than 300 seconds from your clock, so an old request cannot be replayed.
- Answer with a 2xx status as soon as you have stored the event. Do the slow work afterwards.
import hashlib
import hmac
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"]
def valid(header, body):
timestamp, candidates = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
candidates.append(value)
if timestamp is None or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(SECRET.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in candidates)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
ok = valid(self.headers.get("RPT-Signature"), body)
self.send_response(204 if ok else 400)
self.end_headers()
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()Delivery, retries and safe targets
- Each attempt waits at most 8 seconds. Any status other than 2xx counts as a failure. A redirect (3xx) is a failure too: redirects are never followed.
- A failed delivery is tried again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours and 8 hours. That is nine retries over about 24 hours. After that the delivery is
failed. Its state isqueued,deliveredorfailed. - The endpoint URL must be
https, have no user name or password and no fragment, and be at most 2000 characters long. - The host must be a public name. An IP address, a name without a dot, or a name ending in
.local,.internal,.localhost,.lanor.home.arpais refused. So is a name that points to a loopback, private, link-local or otherwise reserved address. The sender connects to the address it checked, so the answer cannot change between the check and the call. - A refused URL fails with
WEBHOOK_URL_REFUSED(HTTP 422);details.reasonisurl_invalid,https_required,target_not_publicordns_failed. A refused target in a later delivery fails at once, without retries.