Specialized models
Video generation
Generate short video clips from a text prompt with Alibaba Wan 3.0 and MiniMax H3
The Video API runs video models that turn a text prompt 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.
| Property | Details |
|---|---|
| Endpoint | https://api.inworld.ai/v1/videos |
| Models | alibaba/wan-3.0, minimax/minimax-h3 |
| Input | A text prompt, plus optional length, resolution and aspect ratio |
| Output | One MP4 video with audio, 2 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. Failed and cancelled jobs are not billed |
Generate your first 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: you get the original job back, and no second job is started or billed. 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 instead of creating another, and the script picks up where it left off. 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):
# Safe to resend: the same key returns the same job.
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) {
// Network errors and timeouts are safe to resend: the same key returns the same job.
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.mp4Models and parameters
Every parameter except model and prompt is optional. A parameter you leave out takes the model's default, and the job you get back shows the value used. Values outside a model's range are rejected with 400 before a job is created.
| Parameter | Wan 3.0 (alibaba/wan-3.0) | MiniMax H3 (minimax/minimax-h3) |
|---|---|---|
seconds | 2 to 30, default 5 | 5 to 15, default 6 |
resolution | 480p (default), 720p, 1080p | 768p (default), 2k |
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 |
prompt length | Up to 2,000 characters | Up to 7,000 characters |
| Audio | Generated with the video | Generated with the video |
seconds is a whole number. resolution is case-insensitive. The request body accepts only these five fields; any other field is rejected, so a parameter is never silently ignored.
The API generates video from text only. Image-to-video, reference images, and extending or remixing an existing video are 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. List entries do not carry download links, so retrieve each completed job once to get its link.
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 are kept for 7 days. After the job's
expires_at, the job still showscompletedbut has nooutput. Copy videos you want to keep to your own storage. - 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:
- A create with a key you already used returns the original job with
200, and starts nothing new. - 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. - A key whose job you deleted can't be reused: the create returns
409withcode: "idempotency_key_reused".
Keep the key somewhere that survives a restart, such as an environment variable, a file, or the database row for the video in your app. A key generated fresh on every run protects nothing: a rerun after a timeout gets a new key and starts a second paid job.
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 | 64 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 video, at the rate for the model and resolution you chose:
| Model | Resolution | Price per second |
|---|---|---|
alibaba/wan-3.0 | 480p | $0.05 |
alibaba/wan-3.0 | 720p | $0.10 |
alibaba/wan-3.0 | 1080p | $0.20 |
minimax/minimax-h3 | 768p | $0.08 |
minimax/minimax-h3 | 2k | $0.13 |
For example, a 5-second Wan 3.0 video at 720p costs $0.50.
- 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. - 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 |
| 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 | Send the create with a new Idempotency-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. For a create, use the same Idempotency-Key so a retry can't start a second job |
| 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.
Coming from other video APIs
When migrating an existing integration, update the endpoint, request fields, status handling, and download logic to match this API. Call it over HTTP rather than through another provider's SDK. Differences that commonly need changes:
- Set
resolutionandaspect_ratio, notsizein pixels. secondsis a number, not a string.- The body is JSON, not a multipart form.
- Download from
output.videos[0].url; there is no/contentendpoint. - Jobs report no
progresspercentage. POST /v1/videos/{video_id}/cancelcancels a job before it starts.