> ## Documentation Index
> Fetch the complete documentation index at: https://docs.inworld.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](https://docs.inworld.ai/api-reference/introduction.md).
- A payment method on your account.
- `curl` and a shell. Run these commands on your server, not in a browser.

<Steps>
<Step title="Set your API key and a request key">

```bash
export INWORLD_API_KEY="<your Base64 API key>"
export VIDEO_REQUEST_KEY="$(uuidgen)"
```

`VIDEO_REQUEST_KEY` is the [idempotency key](#retries-and-idempotency) 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.

</Step>
<Step title="Create a job">

```bash
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:

```json
{
  "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:

```bash
export VIDEO_ID="video_8f14e45fceea167a5a36dedd4bea2543"
```

</Step>
<Step title="Poll until the job finishes">

```bash
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`:

```json
{
  "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](#job-failures).

</Step>
<Step title="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:

```bash
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.

</Step>
</Steps>

## 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](#generate-your-first-video).

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.

<Tabs>
<Tab title="Python">

Requires Python 3.9 or later. Install the `requests` package:

```bash
python -m pip install requests
```

Save this as `generate_video.py`:

```python 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:

```bash
python generate_video.py
```

</Tab>
<Tab title="Node.js">

Requires 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`:

```javascript generate-video.mjs
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:

```bash
node generate-video.mjs
```

</Tab>
</Tabs>

Expected output, with your job's id:

```text
Job video_8f14e45fceea167a5a36dedd4bea2543
queued
in_progress
in_progress
completed
Saved fox.mp4
```

## Models 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](https://docs.inworld.ai/api-reference/videoAPI/list-videos.md) 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](https://google.aip.dev/151), 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:

```bash
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 a `completed` job: the video's `id`, `model`, `duration`, `resolution`, `aspectRatio`, `sizeBytes`, `mimeType` and `expireTime`.
- `error`, for a `failed` or `cancelled` job. For a failed job, the error's reason is the job's `error.code`.

```json
{
  "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_at` says 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 shows `completed` but has no `output`. 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](https://docs.inworld.ai/api-reference/videoAPI/delete-video.md). 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 `400` or `429` starts 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 `409` with `code: "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 `409` with `code: "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](https://docs.inworld.ai/api-reference/videoAPI/cancel-video.md) 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 `completed` jobs are billed. `failed` and `cancelled` jobs cost nothing.
- Billed seconds are the length of the delivered video rounded up to a whole second, and never more than the `seconds` you 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.

```json
{
  "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 `resolution` and `aspect_ratio`, not `size` in pixels.
- `seconds` is a number, not a string.
- The body is JSON, not a multipart form.
- Download from `output.videos[0].url`; there is no `/content` endpoint.
- Jobs report no `progress` percentage.
- `POST /v1/videos/{video_id}/cancel` cancels a job before it starts.
