Video generation
Video generation
Generate short video clips from a text prompt or images with an asynchronous API
The Video API runs video models that turn a text prompt, optionally with images, into a short MP4 clip with sound. Generation takes minutes, so it runs as a job: you create the job, poll its status until it finishes, then download the video.
Model discovery
Browse inworld.ai/models and select Video for model IDs and pricing. Before submitting a job, check the selected model's supported parameters and, if you use images, its image-input support. The tables describe the models shown; they are not an exhaustive catalog.
To list them in code, call the List models API with output_modalities=video. It returns the video models you can call on /v1/videos:
curl -s "https://api.inworld.ai/llm/v1alpha/models?output_modalities=video" \
-H "Authorization: Basic $INWORLD_API_KEY"How it works
Video generation runs as an asynchronous job:
- Create a job with a model, prompt and supported parameters.
- Save the job ID and poll until it reaches a terminal status.
- If the job completed, download the MP4 from
output.videos[0].url. - If the job failed, inspect its
errorbefore retrying.
| Property | Details |
|---|---|
| Endpoint | https://api.inworld.ai/v1/videos |
| Input | A text prompt, plus optional length, resolution and aspect ratio. Optionally a first and last frame (Image-to-video), or reference images, videos and audio (Reference-to-video) |
| Output | One MP4 video with audio, 1 to 30 seconds long, up to 1080p or 2K depending on the model |
| Delivery | Asynchronous: create a job, poll it, download the result |
| Billing | Per second of finished video. Some models also charge for inputs such as frame images or reference videos. Failed and cancelled jobs are not billed |
Generate your first video
This example uses alibaba/wan-3.0 with a five-second, 720p video.
You need:
- An Inworld API key. Copy its Base64 credentials from Portal or the CLI; see authentication.
- A payment method on your account.
curland a shell. Run these commands on your server, not in a browser.
Set your API key and a request key
export INWORLD_API_KEY="<your Base64 API key>"
export VIDEO_REQUEST_KEY="$(uuidgen)"VIDEO_REQUEST_KEY is the idempotency key for this one video. If a create request times out, send it again with the same key: while the original job is retained and has not been deleted, you get it back without starting or billing a second job. Generate a new key for each new video. If uuidgen isn't installed, any unique string of up to 255 characters works.
Create a job
curl -X POST https://api.inworld.ai/v1/videos \
-H "Authorization: Basic $INWORLD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $VIDEO_REQUEST_KEY" \
-d '{
"model": "alibaba/wan-3.0",
"prompt": "A red fox trots across a snowy meadow at sunrise, slow tracking shot, soft golden light",
"seconds": 5,
"resolution": "720p",
"aspect_ratio": "16:9"
}'The API returns the job ID without waiting for generation:
{
"id": "video_8f14e45fceea167a5a36dedd4bea2543",
"object": "video",
"model": "alibaba/wan-3.0",
"status": "queued",
"created_at": 1790000000,
"seconds": 5,
"resolution": "720p",
"aspect_ratio": "16:9",
"operation": "workspaces/my-workspace/videoGenerationJobs/video_8f14e45fceea167a5a36dedd4bea2543/operations/1790000000000-a1b2c3"
}Save the id from your response. Replace the example value with yours:
export VIDEO_ID="video_8f14e45fceea167a5a36dedd4bea2543"Poll until the job finishes
curl "https://api.inworld.ai/v1/videos/$VIDEO_ID" \
-H "Authorization: Basic $INWORLD_API_KEY"Repeat every 10 to 20 seconds until status is completed, failed or cancelled. A completed job includes a download link in output.videos[0].url:
{
"id": "video_8f14e45fceea167a5a36dedd4bea2543",
"object": "video",
"model": "alibaba/wan-3.0",
"status": "completed",
"created_at": 1790000000,
"completed_at": 1790000142,
"expires_at": 1790604942,
"seconds": 5,
"resolution": "720p",
"aspect_ratio": "16:9",
"output": {
"videos": [
{
"index": 0,
"content_type": "video/mp4",
"bytes": 4823311,
"url": "https://storage.googleapis.com/...",
"url_expires_at": 1790003742
}
]
}
}If the job failed, its error.code says why; see job failures.
Download the video
Copy output.videos[0].url from your response. The link is a signed HTTPS URL, so download it without the Authorization header:
export VIDEO_URL="<output.videos[0].url from your response>"
curl -o fox.mp4 "$VIDEO_URL"fox.mp4 is saved in the current directory. The link stops working at url_expires_at; retrieve the job again for a new one.
Generate a video with a script
These scripts run the same create, poll and download flow. They read INWORLD_API_KEY and VIDEO_REQUEST_KEY from the environment, so set both as in step 1.
Because the key comes from the environment, rerunning a script after a timeout or crash returns the same job while it is retained and has not been deleted. After deletion or automatic removal, rerunning the script with that key starts a new paid job. Save the job's id and final result in your own database before deleting it or restarting an old request. To generate a new video, set a new VIDEO_REQUEST_KEY first. A script that generates its own key each run starts a new paid job every time it is restarted.
Requires Python 3.9 or later. Install the requests package:
python -m pip install requestsSave this as generate_video.py:
import os
import time
import requests
API = "https://api.inworld.ai/v1/videos"
HEADERS = {"Authorization": f"Basic {os.environ['INWORLD_API_KEY']}"}
# Reuse one key for every attempt at this video, including after a restart.
REQUEST_KEY = os.environ["VIDEO_REQUEST_KEY"]
def create_video():
for attempt in range(3):
try:
response = requests.post(
API,
headers={**HEADERS, "Idempotency-Key": REQUEST_KEY},
json={
"model": "alibaba/wan-3.0",
"prompt": "A red fox trots across a snowy meadow at sunrise, slow tracking shot, soft golden light",
"seconds": 5,
"resolution": "720p",
"aspect_ratio": "16:9",
},
timeout=30,
)
response.raise_for_status()
return response.json()
except (requests.ConnectionError, requests.Timeout):
# Reuse the key; deletion or automatic removal ends retry protection.
if attempt == 2:
raise
time.sleep(2**attempt)
video_id = create_video()["id"]
print("Job", video_id)
# Retrieve before reading the result: only a retrieve returns the download link.
while True:
response = requests.get(f"{API}/{video_id}", headers=HEADERS, timeout=30)
response.raise_for_status()
video = response.json()
print(video["status"])
if video["status"] not in ("queued", "in_progress"):
break
time.sleep(15)
if video["status"] != "completed":
raise SystemExit(f"Job ended {video['status']}: {video.get('error')}")
# The signed link needs no Authorization header.
url = video["output"]["videos"][0]["url"]
with requests.get(url, stream=True, timeout=300) as download:
download.raise_for_status()
with open("fox.mp4", "wb") as f:
for chunk in download.iter_content(chunk_size=1 << 20):
f.write(chunk)
print("Saved fox.mp4")Run it:
python generate_video.pyRequires Node.js 20 or later. The script uses the built-in fetch and has no dependencies. Save it as generate-video.mjs; the .mjs extension runs it as an ES module, which allows top-level await:
import { writeFile } from 'node:fs/promises';
const API = 'https://api.inworld.ai/v1/videos';
const headers = { Authorization: `Basic ${process.env.INWORLD_API_KEY}` };
// Reuse one key for every attempt at this video, including after a restart.
const requestKey = process.env.VIDEO_REQUEST_KEY;
if (!requestKey) throw new Error('Set VIDEO_REQUEST_KEY first');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(url, init = {}) {
const response = await fetch(url, {
...init,
headers: { ...headers, ...init.headers },
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
return response.json();
}
async function createVideo() {
for (let attempt = 0; ; attempt++) {
try {
return await call(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': requestKey },
body: JSON.stringify({
model: 'alibaba/wan-3.0',
prompt: 'A red fox trots across a snowy meadow at sunrise, slow tracking shot, soft golden light',
seconds: 5,
resolution: '720p',
aspect_ratio: '16:9',
}),
});
} catch (error) {
// Reuse the key; deletion or automatic removal ends retry protection.
const retryable = error.name === 'TypeError' || error.name === 'TimeoutError';
if (!retryable || attempt === 2) throw error;
await sleep(1000 * 2 ** attempt);
}
}
}
const { id } = await createVideo();
console.log('Job', id);
// Retrieve before reading the result: only a retrieve returns the download link.
let video;
while (true) {
video = await call(`${API}/${id}`);
console.log(video.status);
if (video.status !== 'queued' && video.status !== 'in_progress') break;
await sleep(15_000);
}
if (video.status !== 'completed') {
throw new Error(`Job ended ${video.status}: ${JSON.stringify(video.error)}`);
}
// The signed link needs no Authorization header.
const download = await fetch(video.output.videos[0].url);
if (!download.ok) throw new Error(`Download failed: ${download.status}`);
await writeFile('fox.mp4', Buffer.from(await download.arrayBuffer()));
console.log('Saved fox.mp4');Run it:
node generate-video.mjsExpected output, with your job's id:
Job video_8f14e45fceea167a5a36dedd4bea2543
queued
in_progress
in_progress
completed
Saved fox.mp4Request parameters
Only model and prompt are required. Omitted parameters use the model's defaults, and the returned job shows the values used. Values outside the model's range return 400 before a job is created.
| Parameter | Purpose |
|---|---|
model | API model ID selected from the catalog |
prompt | Description of the video to generate |
seconds | Requested video length |
resolution | Output resolution supported by the selected model |
aspect_ratio | Output shape supported by the selected model |
frame_images | First and optional last frame; see Image-to-video |
input_references | Reference images, videos and audio clips; see Reference-to-video |
Supported values and defaults depend on the model. See Model compatibility for the documented models.
seconds is a whole number. resolution is case-insensitive. Besides these fields, the body accepts frame_images and input_references; any other field is rejected, so a parameter is never silently ignored.
To start from a first frame or end on a last frame, see Image-to-video. To guide the video with reference images, videos or audio, see Reference-to-video. Extending or remixing an existing video is not supported.
Job lifecycle
status | Meaning | Terminal |
|---|---|---|
queued | Accepted; waiting for or being submitted for generation. Cancellation may already be unavailable | No |
in_progress | The model is generating the video | No |
completed | Generation succeeded; download available until expires_at | Yes |
failed | Generation did not succeed; error says why | Yes |
cancelled | Cancelled before generation started | Yes |
A job that takes longer than 30 minutes to generate fails with generation_timeout.
Poll for the result
Retrieve the job every 10 to 20 seconds until its status is terminal. Polling more often does not make a job finish sooner. The job object has no progress percentage, so show a spinner or elapsed time rather than a progress bar.
When you track many jobs, list them instead of retrieving each one: a single list call returns up to 100 jobs with their statuses, newest first. Completed jobs whose video has expired are not listed. List entries do not carry download links, so retrieve each completed job once to get its link.
The list is not permanent history. Save job ids and final results in your own database if you need them after expiry or removal.
Poll the operation instead
Each job is also a long-running operation, named in the job's operation field. If your app already polls Inworld operations, you can poll a video job the same way. Save the operation from your create response, replacing the example value with yours:
export OPERATION="workspaces/my-workspace/videoGenerationJobs/video_8f14e45fceea167a5a36dedd4bea2543/operations/1790000000000-a1b2c3"
curl "https://api.inworld.ai/lro/v1alpha/$OPERATION" \
-H "Authorization: Basic $INWORLD_API_KEY"While the job is unfinished, the operation has done: false and metadata.state is QUEUED or IN_PROGRESS. When the job finishes, done becomes true and the operation carries one of:
response, for acompletedjob: the video'sid,model,duration,resolution,aspectRatio,sizeBytes,mimeTypeandexpireTime.error, for afailedorcancelledjob. For a failed job, the error's reason is the job'serror.code.
{
"name": "workspaces/my-workspace/videoGenerationJobs/video_8f14e45fceea167a5a36dedd4bea2543/operations/1790000000000-a1b2c3",
"done": true,
"response": {
"@type": "type.googleapis.com/ai.inworld.video.v1.VideoGenerationResponse",
"id": "video_8f14e45fceea167a5a36dedd4bea2543",
"model": "alibaba/wan-3.0",
"duration": "5s",
"resolution": "720p",
"aspectRatio": "16:9",
"sizeBytes": "4823311",
"mimeType": "video/mp4",
"expireTime": "2026-09-28T14:15:42Z"
}
}The operation never carries the download link. When it is done, retrieve the job by its id to get the link. The operation can lag the job by a few seconds, so treat the job as the source of truth.
Download and storage
- Download links remain valid for up to one hour, capped by the video's retention deadline.
url_expires_atsays when. Each retrieve returns a new link, so retrieve the job again rather than storing the link. - Videos can be downloaded for 7 days. At the job's
expires_at, the video is no longer downloadable and the job is no longer listed. Until the job is removed, retrieving it still returnscompleted, withoutoutput. Copy videos you want to keep to your own storage before expiry. - Finished jobs are removed automatically. A failed or cancelled job becomes eligible for removal 7 days after it ends. A completed job becomes eligible 7 days after its expired video is deleted. A completed job whose usage is still being recorded for billing is kept longer. Cleanup runs periodically and can take longer; do not rely on an exact removal time. Once the job is removed, retrieving it returns
404. - Treat a download link like a password. Anyone who has it can download the video until it expires.
- Delete a video sooner with Delete video. Only finished jobs can be deleted.
- Save your prompt yourself. The job object does not include the prompt, and you can't get it back later.
Retries and idempotency
A create request starts a paid job. If the request times out or the connection drops, you can't tell whether the job was created, and sending it again could start a second one. To retry safely, send an Idempotency-Key header with a unique value, such as a UUID, and reuse the same value when you retry:
- While the original job is retained and has not been deleted, a create with its key returns it with
200and starts nothing new. Video expiry alone does not release the key. - Keys are scoped to your workspace and can be up to 255 characters.
- The body is not compared with the original. A different request sent with a used key still returns the first job, so use a new key for every new video.
- A create that is refused with
400or429starts nothing and doesn't use up the key. Fix the request or wait, then resend it with the same key. - A retry that arrives while the first request with the same key is still being processed returns
409withcode: "idempotency_key_in_use". Wait a few seconds, then resend it with the same key. - Deleting a job releases its key immediately. Automatic job removal also releases the key (see Download and storage). Sending a create with a released key starts a new paid job.
Store the key, job id and final result somewhere that survives a restart, such as your app's database row for the video. Check that record before replaying an old create request: an idempotency key does not prevent duplicates after its job is deleted or automatically removed. Generating a new key on every run also defeats retry protection.
Cancel a job
Cancel video stops a job that has not started generating, and a cancelled job is not billed. Jobs are sent for generation as soon as they are created, so a cancel usually succeeds only right after the create. After that, cancelling returns 409 with code: "video_not_cancellable", even if the job still shows queued, and the job runs to the end.
Limits
| Limit | Value |
|---|---|
| Unfinished jobs per account | 2. A create while 2 jobs are queued or in_progress returns 429 with code: "concurrent_video_jobs_limit" |
| Create requests | Rate limited per account. Excess requests return 429 with the plain-text body Rate limit exceeded |
| Request body | 128 KiB |
| Payment method | Required. Without one, a create returns 400 with code: "payment_method_required" |
| Zero data retention | Not available. Workspaces with zero data retention get 400 with code: "zero_data_retention_unsupported" |
Retrieving, listing, cancelling and deleting jobs is not limited by the job limit.
Billing
You pay per second of finished video, at a rate that depends on the model and the resolution you choose. Some models also charge for inputs you send, such as frame images or reference videos. Current rates are on the models page.
- Only
completedjobs are billed.failedandcancelledjobs cost nothing. - Billed seconds are the length of the delivered video rounded up to a whole second, and never more than the
secondsyou requested. - Reference videos are billed per second of their length, rounded up to a whole second. Image and audio references aren't billed separately.
- Deleting a video does not refund it.
Errors
Video API errors use the JSON structure below, with a machine-readable code and a param naming the field when one is at fault. Authentication, permission, and some rate-limit errors may return plain text instead. Check the HTTP status and response content type before parsing the body.
{
"error": {
"message": "seconds must be an integer from 2 to 30",
"type": "invalid_request_error",
"code": "invalid_request",
"param": "seconds"
}
}| Status | code | What to do |
|---|---|---|
| 400 | invalid_request | Fix the field named in param, then retry |
| 400 | payment_method_required | Add a payment method to your account |
| 400 | zero_data_retention_unsupported | Use a workspace without zero data retention |
| 401, 403 | Plain text | Check your API key and its permissions |
| 404 | video_not_found | The id is wrong, belongs to another workspace, or the job was deleted or automatically removed |
| 409 | video_not_cancellable | Generation has started; let the job finish |
| 409 | video_not_terminal | Wait for the job to finish before deleting it |
| 409 | idempotency_key_in_use | The first request with this Idempotency-Key is still being processed. Wait a few seconds, then resend with the same key |
| 409 | idempotency_key_reused | A concurrent create could not be replayed. Check the original job before starting another with a new key |
| 429 | concurrent_video_jobs_limit | Wait for one of your jobs to finish, then resend with the same Idempotency-Key |
| 429 | Plain text Rate limit exceeded | Too many create requests. Wait, then resend with exponential backoff and the same Idempotency-Key |
| 500 | None, or a plain-text body | Resend with backoff and the same Idempotency-Key. Retry protection ends after deletion or automatic job removal |
| 503 | service_unavailable | Resend later with backoff |
Job failures
A job that fails after it was created returns status: "failed" and an error object on the job itself, not an HTTP error:
error.code | Meaning |
|---|---|
content_filtered | The model provider's content policy blocked the prompt or the generated video. Revise the prompt |
invalid_request | The model provider rejected the request, for example over the prompt's content or length. Revise the prompt |
generation_failed | Generation failed for another reason. Retry with a new Idempotency-Key |
generation_timeout | Generation did not finish within 30 minutes. Retry, or request a shorter or lower-resolution video |
Failed jobs are not billed.
Model compatibility
The following table covers the documented models. Discover model IDs and current pricing in the catalog; use this table to select compatible request parameters.
Supported parameters by model
| Parameter | Wan 3.0 (alibaba/wan-3.0) | MiniMax H3 (minimax/minimax-h3) | MiniMax H3 Fast (inworld/minimax-h3-fast) | MiniMax H3 Turbo (inworld/minimax-h3-turbo) | Grok Imagine Video 1.5 Lite (xai/grok-imagine-video-1.5-lite) |
|---|---|---|---|---|---|
seconds | 2 to 30, default 5 | 5 to 15, default 6 | 5 to 10, default 6 | 5 to 10, default 6 | 1 to 15, default 6 |
resolution | 480p (default), 720p, 1080p | 768p (default), 2k | 768p | 768p | 480p (default), 720p, 1080p |
aspect_ratio | 16:9 (default), 4:3, 1:1, 3:4, 9:16 | 16:9 (default), 21:9, 4:3, 1:1, 3:4, 9:16 | 16:9 (default), 21:9, 4:3, 1:1, 3:4, 9:16 | 16:9 (default), 21:9, 4:3, 1:1, 3:4, 9:16 | 16:9 (default), 3:2, 4:3, 1:1, 3:4, 2:3, 9:16 |
prompt length | Up to 2,000 characters | Up to 7,000 characters | 3 to 7,000 characters | 3 to 7,000 characters | Up to 4,000 characters |
| Audio | Generated with the video | Generated with the video | Generated with the video | Generated with the video | Generated with the video |