# Videos

Render a video from a prompt, or extend an existing clip. Video endpoints are asynchronous. The request returns a job id right away, you poll the job until it is finished, then download the clip. Only video models are served here. Sending any other model returns `400`.

`POST /v1/videos/generations`

`POST /v1/videos/extensions`

`GET /v1/videos/{id}`

`GET /v1/videos/{id}/content`

## Choose a model

Each video model accepts its own durations, resolutions and number of reference images. Read them from the model list:

```bash
curl -s https://kenari.id/v1/models \
  | jq '.data[] | select(.endpoints | index("videos")) | {id, video_durations, video_resolutions, max_reference_images, pricing_lines}'
```

## Request

### Generations

`POST /v1/videos/generations` takes a JSON body and starts a render.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Video model id, for example `seedance-2.0-fast`. |
| `prompt` | string | yes | Description of the video. A few image-to-video models also accept a request with an image and no prompt. |
| `duration` | integer | no | Length in seconds. See [Durations and resolutions](#durations-and-resolutions). |
| `resolution` | string | no | Resolution tier, for example `720p`. Defaults to the first tier the model lists. |
| `image_url` | string | no | Source image for image-to-video. The URL must return the image itself, because a redirect is not followed. |
| `end_image_url` | string | no | Final frame, used together with `image_url`. |
| `input_images` | array of strings | no | One or more reference images, each an https URL or a data URL. See [Reference images](#reference-images). |
| `video_url` | string | no | Reference clip for reference-to-video. If a request has both a clip and an image, it renders as a reference render. |
| `aspect_ratio` | string | no | Output shape, for example `16:9` or `9:16`. kenari does not validate it. It reaches the model unchanged, and its effect depends on the model. |

Sending an image selects the model's image-to-video variant, and sending a clip selects reference-to-video. For this choice, `last_image_url` counts like `end_image_url`, and `reference_video_url` counts like `video_url`. A model that cannot render from the input you sent returns `400` and lists what it accepts.

### Reference images

`input_images` sends reference images. A reference is material the model keeps consistent across the clip, such as a character, a product or a style. It is not a frame, so the clip does not open or end on it. To control the first or last frame, send `image_url` and `end_image_url` instead.

Each entry is an https URL or a data URL. A URL must be reachable from the public internet. How many references a model takes is its `max_reference_images` in the model list:

```bash
curl -s https://kenari.id/v1/models \
  | jq '.data[] | select(.endpoints | index("videos")) | {id, max_reference_images}'
```

- A number above 0 is the most references one request can carry. A request with more returns `400`.
- `0` means the model takes no reference images. A request with `input_images` returns `400`. Send the start frame as `image_url` instead.
- A model without `max_reference_images` has no published figure. It may take references, and a request it cannot take returns `400`.

Some models also refuse a start or end frame in the same request as references. They return `400`, so send frames or references, not both. A reference that cannot be fetched returns `400` with the code `reference_image_fetch_failed`. All of these are rejected before any charge.

### Extensions

`POST /v1/videos/extensions` continues an existing clip from its last frame.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Video model id. |
| `video.url` | string | yes | URL of the clip to extend. |
| `prompt` | string | no | Description of the extension. |
| `duration` | integer | no | Extra length in seconds. Same rules as generations. |
| `resolution` | string | no | Resolution tier, for example `720p`. Same rules as generations: the default is the first tier the model lists, and a tier outside the list returns `400`. The price depends on it. |

The body is JSON. Not every video model can extend a clip. Find the ones that can:

```bash
curl -s https://kenari.id/v1/models | jq -r '.data[] | select(.video_extensions) | .id'
```

If the command prints nothing, no model can extend right now and the extensions endpoint answers `503`. Otherwise, put one of the printed ids in `model`. This request extends a clip:

```json
{
  "model": "<model-id>",
  "video": { "url": "https://example.com/clip.mp4" },
  "prompt": "The eagle lands on a branch",
  "duration": 5,
  "resolution": "720p"
}
```

Replace `<model-id>` with an id from the command above. A model whose entry lacks `video_extensions` answers `503`, and nothing is charged.

### Durations and resolutions

A model that lists `video_durations` accepts only those values. The first entry is the default, and any other value returns `400` with the allowed list. A model without the list accepts `duration` from 1 to 15 with a default of 6, and moves a value outside that range to the nearest limit. `duration` must be a whole number of seconds.

A model that lists `video_resolutions` accepts only those tiers, and the first is the default. Tier names are matched without regard to case. A tier outside the list returns `400` with the available tiers. kenari never switches you to a cheaper tier. A model that lists no tiers has one rate and ignores `resolution`.

Invalid durations or resolutions are rejected before any charge.

## Response

Both create endpoints return the job at once. The clip does not exist yet.

```json
{
  "id": "7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47",
  "object": "video.job",
  "status": "rendering",
  "model": "seedance-2.0-fast"
}
```

## Polling

`GET /v1/videos/{id}` returns the current state of a job. Poll every few seconds until `status` is no longer `rendering`. Only the account that created the job can read it.

| Status | Meaning |
| --- | --- |
| `rendering` | The job is still running. Keep polling. |
| `done` | The clip is ready. `url` holds the download link. |
| `failed` | The render failed. The charge is refunded. |
| `expired` | The job did not finish in time. The charge is refunded. |

A poll that returns `429` or `503` is a temporary HTTP failure, not a failed job. Wait and poll again. Any other error status ends the loop. Only `failed` and `expired` are final job statuses besides `done`.

A finished job:

```json
{
  "id": "7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47",
  "status": "done",
  "url": "https://kenari.id/v1/videos/7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47/content"
}
```

A `failed` or `expired` job has no `url`. It carries `failure_reason` instead, a short message in Indonesian that says what happened, for example that the capacity is full and you can retry, or that the prompt or image was refused by the content policy.

A job that has not finished after one hour is closed as `expired` and refunded.

## Downloading the clip

`GET /v1/videos/{id}/content` streams the finished clip. The link in `url` is served by kenari and needs a valid API key that belongs to the account that created the job. Other accounts cannot download it. The `Content-Type` is `video/mp4`, `video/webm` or `video/quicktime`. While the job is still rendering, the endpoint returns `400`.

```bash
curl https://kenari.id/v1/videos/7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47/content \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  --output clip.mp4
```

## Examples

### curl

```bash
JOB=$(curl -s https://kenari.id/v1/videos/generations \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "seedance-2.0-fast",
    "prompt": "An eagle flying over a rainforest at sunrise",
    "duration": 5,
    "resolution": "720p"
  }' | jq -r '.id // empty')

if [ -z "$JOB" ]; then
  echo "The job was not created" >&2
  exit 1
fi

while true; do
  RESULT=$(curl -s "https://kenari.id/v1/videos/$JOB" \
    -H "Authorization: Bearer $KENARI_API_KEY")
  STATUS=$(echo "$RESULT" | jq -r '.status // empty')
  if [ -z "$STATUS" ]; then
    echo "$RESULT" >&2
    exit 1
  fi
  echo "status: $STATUS"
  [ "$STATUS" != "rendering" ] && break
  sleep 5
done

if [ "$STATUS" = "done" ]; then
  curl -s --fail "https://kenari.id/v1/videos/$JOB/content" \
    -H "Authorization: Bearer $KENARI_API_KEY" \
    --output clip.mp4
else
  echo "$RESULT" | jq -r '.failure_reason'
fi
```

### Python

```python
import os
import time

import requests

BASE = "https://kenari.id/v1"
headers = {"Authorization": f"Bearer {os.environ['KENARI_API_KEY']}"}

job = requests.post(
    f"{BASE}/videos/generations",
    headers=headers,
    json={
        "model": "seedance-2.0-fast",
        "prompt": "An eagle flying over a rainforest at sunrise",
        "duration": 5,
        "resolution": "720p",
    },
    timeout=60,
)
job.raise_for_status()
job_id = job.json()["id"]

while True:
    poll = requests.get(f"{BASE}/videos/{job_id}", headers=headers, timeout=30)
    if poll.status_code in (429, 503):
        time.sleep(5)
        continue
    poll.raise_for_status()
    state = poll.json()
    print("status:", state["status"])
    if state["status"] != "rendering":
        break
    time.sleep(5)

if state["status"] == "done":
    clip = requests.get(f"{BASE}/videos/{job_id}/content", headers=headers, timeout=300)
    clip.raise_for_status()
    with open("clip.mp4", "wb") as f:
        f.write(clip.content)
else:
    print(state.get("failure_reason"))
```

### JavaScript

```js
import fs from "node:fs";

const BASE = "https://kenari.id/v1";
const headers = {
  Authorization: `Bearer ${process.env.KENARI_API_KEY}`,
  "Content-Type": "application/json",
};

const created = await fetch(`${BASE}/videos/generations`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    model: "seedance-2.0-fast",
    prompt: "An eagle flying over a rainforest at sunrise",
    duration: 5,
    resolution: "720p",
  }),
});
if (!created.ok) throw new Error(await created.text());
const { id } = await created.json();

let state;
do {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  const poll = await fetch(`${BASE}/videos/${id}`, { headers });
  if (poll.status === 429 || poll.status === 503) {
    state = { status: "rendering" };
    continue;
  }
  if (!poll.ok) throw new Error(await poll.text());
  state = await poll.json();
  console.log("status:", state.status);
} while (state.status === "rendering");

if (state.status === "done") {
  const clip = await fetch(`${BASE}/videos/${id}/content`, { headers });
  if (!clip.ok) throw new Error(await clip.text());
  fs.writeFileSync("clip.mp4", Buffer.from(await clip.arrayBuffer()));
} else {
  console.log(state.failure_reason);
}
```

## Billing

Video is billed per second of the requested duration, at the rate of the resolution tier you ask for. A 5-second render at 720p costs 5 times the model's 720p rate per second. Each tier has its own rate in `pricing_lines` of `GET /v1/models`. The full amount is taken from your balance when the job is accepted. If the job ends as `failed` or `expired`, it is refunded automatically, whether or not you keep polling. See [How billing works](/en/docs/billing) for the rest.

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The model is not a video model, `prompt` or `video.url` is missing, the duration or resolution is not available for the model, the model cannot render from the input you sent, or the reference images are more than the model's `max_reference_images` or are mixed with a start or end frame. |
| 400 | `reference_image_fetch_failed` | A reference image in `input_images` could not be fetched. Send each reference as a public https URL or a data URL. Nothing is charged. |
| 400 | `bad_request` | On the download endpoint: the clip is not ready yet. |
| 400 | `model_not_found` | The job id does not exist or belongs to another account. |
| 402 | `insufficient_balance` | The balance does not cover the full price of the job. |
| 503 | `upstream_error`, `all_providers_failed` | The model is not available right now, or on `/v1/videos/extensions` the model's entry lacks `video_extensions`. Nothing is charged. Retry once or twice, and if it keeps failing on an extension, choose a model that lists `video_extensions`. |

See [Errors](/en/docs/errors) for every other code.

## See also

- [Models and pricing](/en/docs/models)
- [How billing works](/en/docs/billing)
- [Errors](/en/docs/errors)
