Skip to content
kenari.

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

Each video model accepts its own durations and resolutions. Read them from the model list:

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

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

FieldTypeRequiredDescription
modelstringyesVideo model id, for example seedance-2.0-fast.
promptstringyesDescription of the video. A few image-to-video models also accept a request with an image and no prompt.
durationintegernoLength in seconds. See Durations and resolutions.
resolutionstringnoResolution tier, for example 720p. Defaults to the first tier the model lists.
image_urlstringnoSource image for image-to-video. The URL must return the image itself, because a redirect is not followed.
end_image_urlstringnoFinal frame, used together with image_url.
input_imagesarray of stringsnoOne or more source images, as an alternative to image_url.
video_urlstringnoReference clip for reference-to-video. If a request has both a clip and an image, it renders as a reference render.
aspect_ratiostringnoOutput 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.

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

FieldTypeRequiredDescription
modelstringyesVideo model id.
video.urlstringyesURL of the clip to extend.
promptstringnoDescription of the extension.
durationintegernoExtra length in seconds. Same rules as generations.
resolutionstringnoResolution 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:

Terminal window
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.

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.

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"
}

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.

StatusMeaning
renderingThe job is still running. Keep polling.
doneThe clip is ready. url holds the download link.
failedThe render failed. The charge is refunded.
expiredThe 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.

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.

Terminal window
curl https://kenari.id/v1/videos/7b0e2c9a-4f1d-4c3e-9a55-2d8f6e1b0c47/content \
-H "Authorization: Bearer $KENARI_API_KEY" \
--output clip.mp4
Terminal window
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
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"))
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);
}

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.

StatusCodeWhen
400bad_requestThe 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.
400bad_requestOn the download endpoint: the clip is not ready yet.
400model_not_foundThe job id does not exist or belongs to another account.
402insufficient_balanceThe balance does not cover the full price of the job.
503upstream_error, all_providers_failedThe 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.