Call the Varosity API from Python
Generate video from any Python agent with plain requests: submit a job, poll it, download the MP4. One API key, no SDK to install.
Call the Varosity API from Python
There is no Varosity Python package to install — the API is plain HTTPS +
JSON, so the requests library is all an agent needs. This guide shows the
whole loop: submit → poll → download.
Looking for the old "Agent Video SDK" page? The varosity_sdk package it described was never published. Everything it promised is covered by the REST calls below (or by the MCP server and the CLI).Before you start
1. Issue an API key (vsk_…) at https://varosity.ai/app/keys/api-keys.
Give it the generate:video scope (add generate:image,
generate:voice, generate:music if you need them).
2. Export it — never hard-code it:
export VAROSITY_API_KEY="vsk_..." pip install requests
Every request goes to https://varosity.ai with an
Authorization: Bearer <key> header.
The three calls
| Step | Call | Returns |
| Pick a model | GET /api/v1/models?kind=video | { ok, data: [{ id, maxDurationSec, aspectRatios, pricePerSec, creditsPricePerSec, … }], count } |
| Submit | POST /api/v1/video/generate | { ok, jobId, projectId, status, outputUrl, billingMode } |
| Poll | GET /api/v1/jobs/{jobId} | { ok, jobId, status, outputUrl, thumbnailUrl, error, code?, costCents } |
The submit response's status is normally rendering (a fast model may
already report succeeded). When polling, status is one of queued,
rendering, succeeded, failed, or canceled. outputUrl is null
until the job has succeeded.
Held renders. On Varosity Credits, a render that finishes when your
balance can't cover it is held: the poll keeps returning rendering (or
queued) with code: "insufficient_credits" and an error message
naming the amount. Waiting won't release it. Add credits at
/app/credits, then poll again: the next poll charges the render and
returns succeeded with the outputUrl. A held render is kept for 24
hours. The poll loop below stops and reports this instead of waiting.
Request body for POST /api/v1/video/generate
| Field | Type | Notes |
prompt | string, required | Up to 2000 characters. |
modelId | string, required | A video model id from /api/v1/models, e.g. kling-3.0, veo-3.1, seedance-1.0-pro. |
aspectRatio | string | 16:9 (default), 9:16, 1:1, 4:5, 21:9 — must be one the model supports (check the model's aspectRatios in GET /api/v1/models; otherwise HTTP 400 unsupported_aspect_ratio). |
durationSec | integer | Default 5. Above the model's maxDurationSec the request is refused with HTTP 400 duration_exceeds_model_max. |
referenceImageUrl | URL | Public image for image-to-video / subject consistency. |
referenceVideoUrl | URL | Driving video for performance-transfer models. |
audioUrl | URL | Audio track for avatar / lip-sync models. |
providerOptions | object | Model-specific knobs — see Provider options. |
Complete example
import os import time
import requests
BASE = "https://varosity.ai" HEADERS = { "Authorization": f"Bearer {os.environ['VAROSITY_API_KEY']}", "Content-Type": "application/json", }
def submit_video(prompt, model_id="kling-3.0", aspect_ratio="16:9", duration_sec=5, reference_image_url=None): """Submit a render. Returns the response JSON (contains jobId).""" body = { "prompt": prompt, "modelId": model_id, "aspectRatio": aspect_ratio, "durationSec": duration_sec, } if reference_image_url: body["referenceImageUrl"] = reference_image_url
res = requests.post(f"{BASE}/api/v1/video/generate", headers=HEADERS, json=body, timeout=90) data = res.json() if not res.ok or not data.get("ok"): raise RuntimeError( f"submit failed ({res.status_code}): " f"{data.get('code')} — {data.get('error')}" ) return data
def wait_for_job(job_id, interval_sec=5, timeout_sec=900): """Poll until the job succeeds or fails. Returns the final job JSON.""" deadline = time.time() + timeout_sec while time.time() < deadline: res = requests.get(f"{BASE}/api/v1/jobs/{job_id}", headers=HEADERS, timeout=60) job = res.json() if not res.ok or not job.get("ok"): raise RuntimeError(f"poll failed ({res.status_code}): {job.get('error')}") if job["status"] == "succeeded": return job if job["status"] in ("failed", "canceled"): raise RuntimeError(f"job {job['status']}: {job.get('error')}") if job.get("code") == "insufficient_credits": # Finished but held until paid: top up, then poll again. raise RuntimeError(f"job {job_id} held: {job.get('error')}") time.sleep(interval_sec) raise TimeoutError(f"job {job_id} still running after {timeout_sec}s")
def download(url, path): with requests.get(url, stream=True, timeout=120) as res: res.raise_for_status() with open(path, "wb") as fh: for chunk in res.iter_content(chunk_size=1 << 16): fh.write(chunk) return path
if __name__ == "__main__": submitted = submit_video( "Wide cinematic shot of a coastal cliff at golden hour, slow drone push-in", model_id="kling-3.0", duration_sec=5, ) print("jobId:", submitted["jobId"])
Some models finish inside the submit call; most return "rendering".
job = submitted if submitted["status"] == "succeeded" else wait_for_job(submitted["jobId"]) print("outputUrl:", job["outputUrl"]) download(job["outputUrl"], "shot.mp4") ```
Poll every 3–5 seconds. Most clips land in 30–120 seconds; long-form avatar models can take several minutes.
Several shots at once
Each POST /api/v1/video/generate call is independent, so submit every
shot first and then poll them together:
shots = [
("Wide establishing shot of a mountain lodge at dawn", "kling-3.0"),
("Close-up of hands pouring coffee, steam rising", "veo-3.1"),
("Skier carving through powder, tracking shot", "seedance-1.0-pro"),
]
job_ids = [submit_video(prompt, model_id=m)["jobId"] for prompt, m in shots]
urls = [wait_for_job(job_id)["outputUrl"] for job_id in job_ids]Each REST call creates its own single-shot project, so join the clips
yourself (for example with ffmpeg), or build the shots inside one project
through the MCP server (create_project →
generate_video with project_id → render_project) to get a
server-side stitch.
Errors worth handling
Failures come back as { "ok": false, "error": "…", "code": "…" }.
| HTTP | code | What to do |
| 400 | invalid_request | The body failed validation; error names the field. |
| 400 | unknown_model | Typo in modelId — list ids with GET /api/v1/models. |
| 400 | not_a_video_model | You passed an image/voice/music model; use that kind's endpoint. |
| 400 | duration_exceeds_model_max | durationSec is above the model's maxDurationSec; lower it. |
| 400 | unsupported_aspect_ratio | Pick one of the model's aspectRatios. |
| 400 | reference_required | This model needs a start image: send referenceImageUrl. |
| 400 | reference_not_supported | This model is text-only and would ignore the image; drop referenceImageUrl or pick a model that takes one. |
| 400 | invalid_input | The provider rejected a parameter; error says which. Fix it before retrying. |
| 402 | insufficient_credits | (/v1/tts, /v1/music) Not enough credits for this call, so nothing was delivered. Top up at /app/credits, then retry. |
| 401 | unauthorized | reason is missing_api_key (no Authorization: Bearer vsk_… header) or invalid_api_key (mistyped, revoked or expired). hint says what to fix; handoff is a message you can pass to your user to get a key. To get a key without a human pasting one, follow agent_connect (POST it, show your user the approval link, poll); agent_key is the one-click key page. |
| 403 | insufficient_scope | The key lacks the required scope (e.g. generate:video). |
| 409 | auth_not_configured | No way to pay for this model — add the provider's key at /app/keys or top up credits at /app/credits. |
| 409 | other | The model is unavailable right now — the error text names a replacement. |
| 429 | rate_limited | Too many requests per minute. Honour the Retry-After header, then retry. |
| 429 | daily_cap_exceeded | This key's daily spend cap is used up. The body carries spent_today_cents and daily_cap_cents; there is no Retry-After. Don't retry — it fails until the cap resets at 00:00 UTC or you raise it at /app/keys/api-keys. |
| 500 | generate_failed | Unexpected failure; nothing was charged. Retry once, then try another model. |
| 503 | spend_guard_error | The spend cap couldn't be checked; nothing was generated. Retry shortly. |
| 503 | circuit_open | The provider is failing right now and calls to it are paused; nothing was charged. Retry in a minute or pick another model. |
The daily cap is set per API key at /app/keys/api-keys. Each call
reserves its estimated cost. Media-driven models (OmniHuman, lip-sync,
LivePortrait, Wan Animate, HeyGen, D-ID) bill the real length of the
supplied audio or video, and so do transcription, dubbing and the voice
changer; on Varosity Credits that real charge is added to
the key's day when the job finishes — so the next call can be refused even though
this one was accepted.
To make a retry safe, send an Idempotency-Key header (any unique string
per logical request) on the POST — a repeat with the same key returns the
original response instead of starting a second render. On
POST /v1/video/generate and /v1/images a repeat sent while the first is
still running gets 409 idempotency_in_flight (reusing a key on a different endpoint gets 409 idempotency_key_reused on any route that reads the header); on /v1/sfx, /tts, /music,
/v1/audio/assemble, /v1/projects/{projectId}/stitch and the run routes a
repeat returns the stored response only once the first has finished. Other
POSTs ignore the header, so check before retrying a voice clone,
transcription or dub.
Images, voice, and music
The same key and the same pattern work for the synchronous endpoints — no polling, the URL is in the response:
def post(path, body):
res = requests.post(f"{BASE}{path}", headers=HEADERS, json=body, timeout=300)
data = res.json()
if not res.ok or not data.get("ok"):
raise RuntimeError(f"{path} failed ({res.status_code}): {data.get('error')}")
return dataimage = post("/api/v1/images", { "prompt": "Product hero shot of a matte black espresso machine, studio light", "modelId": "flux-1.1-pro", "aspectRatio": "16:9", })["imageUrl"]
voice = post("/api/v1/tts", { "text": "Welcome back. Here's what's new this week.", "voiceId": "21m00Tcm4TlvDq8ikWAM", })["audioUrl"]
music = post("/api/v1/music", { "modelId": "elevenlabs-music", "prompt": "cinematic synth, slow build, hopeful, instrumental", "durationSec": 30, })["audioUrl"] ```
durationSec for music is 5–300 (each model has its own maximum; Lyria 2
always makes one 30-second clip). Some music models run asynchronously and
return { trackId, providerJobId, sync: false } with no audioUrl yet —
poll POST /api/music/poll with { "trackId": "…" } until it returns
status: "succeeded" and an audioUrl. elevenlabs-music returns the URL
directly.
See also
- Agent mode (MCP) — the same capabilities as MCP tools
- Using the CLI —
varosity generate video …from a shell - Picking the right model
- Provider options — per-model
providerOptions - OpenAPI spec