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:

  1. POST /renders accepts your prompt and answers 202 at once with an id.
  2. GET /renders/{id} tells you where it is; when status is succeeded, output.url downloads the MP4.
  3. Or give a webhook_url and we call you when it is done.

Every render is an object with a state: queuedrunning 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:

FieldTypeNotes
promptstring, requiredWhat the camera sees. Up to 4 000 characters. English works best.
secondsinteger4 to 8. Default 5. These are the engine's bounds.
aspectstring16:9 (1280×736), 9:16 or 1:1. Default 16:9.
seedintegerFix it to reproduce a render. Random when absent.
webhook_urlstringWe POST the finished render there. See Webhooks.

Headers:

HeaderNotes
AuthorizationBearer klm_live_… (required)
Idempotency-KeyOptional. Retry safely. See Idempotency.
X-Klemet-VersionOptional. 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"
  }
}
typeHTTPMeaning
authentication_error401No key, an unknown key, or a revoked key.
invalid_request_error400A missing or malformed field. `param` names it.
insufficient_funds402Your wallet cannot cover the estimate. `balance_usd` says what is left.
rate_limit_error429Too many requests. `Retry-After` says when to come back.
not_found404No render with this id on your account.
idempotency_error409This Idempotency-Key was already used with a different body.
engine_error502The engine refused or failed the render. You were not charged.
empty_output200The 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
CardH100, 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 submit0.0373 $ (the average), corrected to the real price when done
Empty or failed render0 $ — 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.