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
Section titled “Choose a model”Each video model accepts its own durations and resolutions. Read them from the model list:
curl -s https://kenari.id/v1/models \ | jq '.data[] | select(.endpoints | index("videos")) | {id, video_durations, video_resolutions, pricing_lines}'Request
Section titled “Request”Generations
Section titled “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. |
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 source images, as an alternative to image_url. |
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.
Extensions
Section titled “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:
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:
{ "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
Section titled “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
Section titled “Response”Both create endpoints return the job at once. The clip does not exist yet.
{ "id": "7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47", "object": "video.job", "status": "rendering", "model": "seedance-2.0-fast"}Polling
Section titled “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:
{ "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
Section titled “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.
curl https://kenari.id/v1/videos/7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47/content \ -H "Authorization: Bearer $KENARI_API_KEY" \ --output clip.mp4Examples
Section titled “Examples”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 1fi
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 5done
if [ "$STATUS" = "done" ]; then curl -s --fail "https://kenari.id/v1/videos/$JOB/content" \ -H "Authorization: Bearer $KENARI_API_KEY" \ --output clip.mp4else echo "$RESULT" | jq -r '.failure_reason'fiPython
Section titled “Python”import osimport 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
Section titled “JavaScript”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
Section titled “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 for the rest.
Errors
Section titled “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, or the model cannot render from the input you sent. |
| 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 for every other code.