Version 2026-09-12
Klemet API
One prompt in, one video out. Renders run on Klemet's own cards (LTX 2.5 on H100) and are billed to your wallet at the card's real price. Two requests are enough: submit, then read.
Base URL https://klemet.app/api/v1. All requests and responses are JSON.
Overview
A render takes minutes, and no HTTP request should stay open that long. So the API is asynchronous, the way every video API is:
POST /rendersaccepts your prompt and answers202at once with anid.GET /renders/{id}tells you where it is; whenstatusissucceeded,output.urldownloads the MP4.- Or give a
webhook_urland we call you when it is done.
Every render is an object with a state: queued → running → succeeded or failed. A failed render is refunded.
Authentication
Create a key at klemet.app/app/api. It starts with klm_live_ and is shown once. Send it as a Bearer token on every request:
Authorization: Bearer klm_live_…Never put the key in a URL and never ship it to a browser: it spends your wallet. Lost it? Revoke it and create another — we only store a hash, so nobody can read it back, not even us.
Quickstart
Submit a four-second render, then poll until it is done.
curl
export KLEMET_KEY=klm_live_…
# 1. Submit
curl -s -X POST https://klemet.app/api/v1/renders \
-H "Authorization: Bearer $KLEMET_KEY" \
-H "content-type: application/json" \
-d '{"prompt":"A clay amphora on packed earth, warm afternoon light, slow push in.","seconds":4}'
# → 202 {"id":"c2737017ed54","status":"queued",...}
# 2. Read (repeat every few seconds until status is succeeded or failed)
curl -s https://klemet.app/api/v1/renders/c2737017ed54 \
-H "Authorization: Bearer $KLEMET_KEY"
# → {"status":"succeeded","output":{"url":"https://…"},"usd":0.0434}Node
const KEY = process.env.KLEMET_KEY;
const base = "https://klemet.app/api/v1";
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const submitted = await fetch(`${base}/renders`, {
method: "POST",
headers,
body: JSON.stringify({ prompt: "A clay amphora on packed earth, warm light.", seconds: 4 }),
}).then((r) => r.json());
let render = submitted;
while (render.status === "queued" || render.status === "running") {
await new Promise((r) => setTimeout(r, 5000));
render = await fetch(`${base}/renders/${submitted.id}`, { headers }).then((r) => r.json());
}
console.log(render.status, render.output?.url, render.usd);Python
import os, time, requests
KEY = os.environ["KLEMET_KEY"]
BASE = "https://klemet.app/api/v1"
H = {"Authorization": f"Bearer {KEY}"}
render = requests.post(f"{BASE}/renders", headers=H, json={
"prompt": "A clay amphora on packed earth, warm light.",
"seconds": 4,
}).json()
while render["status"] in ("queued", "running"):
time.sleep(5)
render = requests.get(f"{BASE}/renders/{render['id']}", headers=H).json()
print(render["status"], render.get("output", {}).get("url"), render.get("usd"))Measured on 2026-09-12: a 4 s render answers 202 in under a second, and reaches succeeded in about 30 s when a card is warm, up to two minutes when one has to start.
Create a render
POST/renders
Request body:
| Field | Type | Notes |
|---|---|---|
| prompt | string, required | What the camera sees. Up to 4 000 characters. English works best. |
| seconds | integer | 4 to 8. Default 5. These are the engine's bounds. |
| aspect | string | 16:9 (1280×736), 9:16 or 1:1. Default 16:9. |
| seed | integer | Fix it to reproduce a render. Random when absent. |
| webhook_url | string | We POST the finished render there. See Webhooks. |
Headers:
| Header | Notes |
|---|---|
| Authorization | Bearer klm_live_… (required) |
| Idempotency-Key | Optional. Retry safely. See Idempotency. |
| X-Klemet-Version | Optional. 2026-09-12 is the only version today. |
Response 202 Accepted, with a Location header pointing at the render:
{
"id": "c2737017ed54",
"object": "render",
"status": "queued",
"created_at": "2026-09-12T15:04:05.000Z",
"finished_at": null,
"input": { "prompt": "…", "seconds": 4, "aspect": "16:9", "seed": 1837201 },
"output": null,
"estimated_usd": 0.0373,
"usd": null,
"error": null
}The estimate is charged to your wallet when you submit. When the render finishes, the charge is corrected to the card's real price and usd is filled in.
Read a render
GET/renders/{id}
Poll every few seconds. When status is succeeded:
{
"id": "c2737017ed54",
"object": "render",
"status": "succeeded",
"created_at": "2026-09-12T15:04:05.000Z",
"finished_at": "2026-09-12T15:04:41.000Z",
"input": { "prompt": "…", "seconds": 4, "aspect": "16:9", "seed": 1837201 },
"output": {
"url": "https://…r2.cloudflarestorage.com/…",
"expires_at": "2026-09-13T15:04:41.000Z",
"width": 1280, "height": 736, "seconds": 4
},
"estimated_usd": 0.0373,
"usd": 0.0434,
"error": null
}output.url is a signed link, valid 24 hours. Download the file and keep it: the link expires, the file does not. When status is failed, error.type says why and you were refunded.
List renders
GET/renders?limit=50&starting_after={id}
Your renders, newest first. Fifty per page by default, one hundred at most.
{ "object": "list", "data": [ { "id": "…", "object": "render", … } ], "has_more": false }To page, pass the last id you received as starting_after.
Webhooks
Give a webhook_url when you submit. When the render ends we POST the same object GET /renders/{id} would return, with two headers:
X-Klemet-Event: render.succeeded (or render.failed)
X-Klemet-Signature: t=1789230000,v1=5f1a… (HMAC-SHA256)Verify the signature with the webhook signing secret of the key that submitted the render (whsec_…, shown at creation and revealable at klemet.app/app/api). The signed payload is `${t}.${rawBody}`:
curl
# Pseudo-shell: compute HMAC-SHA256 of "<t>.<raw body>" with your whsec_ secret
# and compare it, constant-time, with v1. Reject if |now - t| > 300 s.Node
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Python
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Answer 2xx quickly and do the work after. If we do not get a 2xx, we retry after 1, 5 and 25 minutes, then stop; GET always works as a fallback.
Idempotency
A connection can drop after we accepted your render but before you read our answer. To retry safely, send an Idempotency-Key header (any unique string up to 255 characters — a UUID v4 is ideal):
curl -X POST https://klemet.app/api/v1/renders \
-H "Authorization: Bearer $KLEMET_KEY" \
-H "Idempotency-Key: 6f1c1e2a-3b4d-4c5e-9f00-1a2b3c4d5e6f" \
-H "content-type: application/json" \
-d '{"prompt":"…"}'For 24 hours, the same key with the same body returns the same render — and charges you once. The same key with a different body is refused with 409 idempotency_error.
Errors
Every error is one object. type is for your code, message is for you, param names the field when one is at fault:
{
"error": {
"type": "insufficient_funds",
"message": "Not enough credit. Top up at klemet.app/app/billing.",
"param": null,
"doc_url": "https://docs.klemet.app/#errors"
}
}| type | HTTP | Meaning |
|---|---|---|
| authentication_error | 401 | No key, an unknown key, or a revoked key. |
| invalid_request_error | 400 | A missing or malformed field. `param` names it. |
| insufficient_funds | 402 | Your wallet cannot cover the estimate. `balance_usd` says what is left. |
| rate_limit_error | 429 | Too many requests. `Retry-After` says when to come back. |
| not_found | 404 | No render with this id on your account. |
| idempotency_error | 409 | This Idempotency-Key was already used with a different body. |
| engine_error | 502 | The engine refused or failed the render. You were not charged. |
| empty_output | 200 | The engine returned an empty clip (status `failed`). You were refunded; submit again. |
Rate limits
Per account: 20 requests per minute and 300 per hour. Beyond that, 429 with a Retry-After header in seconds. Ten cards render in parallel; a queue forms above that, which is normal — your renders are not lost, they wait.
Pricing
You pay the card. Nothing else, no minimum, no plan required for the API. Measured on 2026-09-12 across 56 renders:
| Value | |
|---|---|
| Card | H100, 0.001203 $ per second of card time |
| A 4–8 s render, warm card | ≈ 25 s of card · ≈ 0.030 $ |
| First render when a card starts | ≈ 62 s of card · ≈ 0.075 $ |
| Estimate charged at submit | 0.0373 $ (the average), corrected to the real price when done |
| Empty or failed render | 0 $ — refunded |
Top up your wallet at klemet.app/app/billing. Every render shows on your API page with its real price.
Versioning
This is version 2026-09-12, the first. Send X-Klemet-Version: 2026-09-12 if you want to pin it; every response carries the version it was served with. When something changes in a way that could break you, a new date will be published here and the old one will keep working.