API
One resource: a generation. Create it, poll it, download the mp4. Base URL https://api.highlander.sh — the same routes are served from https://highlander.sh.
Authentication
Every request carries a bearer key from your dashboard. Keys look like hl_live_…, are shown once, and are stored only as a hash — if you lose one, revoke it and make another.
Authorization: Bearer hl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxPOST /v1/generations
Returns 202 immediately with a job id. Credit is reserved at this point, at $0.015 per second of output video ($0.22 for a full 14.375 s clip), and returned if the job does not produce a video.
curl -X POST https://api.highlander.sh/v1/generations \
-H "Authorization: Bearer $HIGHLANDER_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt":"Waves break over black volcanic rock at dusk.","frames":345,"steps":5}'| Field | Type | Notes |
|---|---|---|
| prompt | string, required | Up to 800 characters. Describes shot, subject, camera and light. |
| negative_prompt | string | Optional; accepted and ignored by the current model. |
| frames | int, default 345 | 124..345, and must satisfy frames % 17 == 5 — H3's causal VAE packs 17 frames into 5 latents, and the model floors at 5 s. The presets are 124 / 243 / 345 (5.17 / 10.13 / 14.38 s — the legal counts closest to 5/10/15 s; exactly 15 s does not exist, 14.375 s is the model's ceiling). Any other legal count works just as well. |
| first_frame_url | string (selects fl2v) | Public http(s) URL of an image that becomes the clip's first frame. The worker fetches it itself (SSRF-guarded, 256 MB ceiling), so it must be reachable from the internet — POST /v1/uploads if you have a local file. The image is cover-cropped to 16:9 and resized to 1344x768, so anything off-ratio loses its edges rather than being letterboxed. |
| steps | int, fixed at 5 | Accepted for compatibility and must be 5: the hosted model runs a fixed 4-forward distilled schedule, so there is nothing to scale and nothing to charge for. |
| seed | int, default 1000 | Same seed and prompt reproduce the same clip. |
| width / height | 1344 / 768 | Fixed. The model serves one resolution; anything else is rejected before anything is charged. |
Modes
Two modes, both served by MiniMax FastH3. Which fields you send selects the mode: no media fields means text-to-video, first_frame_url means first-frame conditioning. You never choose a mode explicitly and there is no mode mismatch to handle.
Both modes are always resident — there is no pipeline to swap in, so the first request in a mode is no slower than the tenth. The only wait is the model session: after about 20 s without traffic the worker closes it, and the next job spends a few seconds reopening it before generating. While that happens the poll response says so: pipeline_state reads "connecting" and then "generating" rather than a bare queued. You are billed for the clip, never for the wait.
GET /v1/health still reports modes — per-mode availability. A mode reported unavailable (subject references, on the current backend) is refused with 503 mode_unavailable before anything is charged.
| t2v (default) | text → video + audio | ~12 s to generate a 14.375 s clip, then the clip is captured from a real-time playout, so expect ~27-30 s end to end. |
| fl2v | first-frame image → video + audio | Your image becomes the first frame. Same timing rule: generation plus playout. |
steps is fixed at 5: every clip runs the same distilled schedule, so the pricing formula does not change between modes.
curl -X POST https://api.highlander.sh/v1/generations \
-H "Authorization: Bearer $HIGHLANDER_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt":"The painting comes alive; she turns and smiles.",
"first_frame_url":"https://example.com/portrait.png","frames":124}'POST /v1/uploads
The worker fetches first_frame_url itself, so conditioning media has to sit at a public http(s) URL before you can generate with it. If yours already does, skip this section. If it is a local file, this endpoint gives you somewhere to put it.
There is no multipart form to post: an upload may be 256 MB and a serverless request body is capped at 4.5 MB, so the bytes deliberately never pass through highlander.sh. Instead you ask for a client token — good for one pathname, one content type, one size cap, one hour — and PUT the file straight to blob storage with it. Three requests, no SDK. The hl CLI and the playground both do all of this for you.
| 1. GET /v1/uploads | Returns prefix (uploads/<account>/<yyyy-mm>/), the size caps and the accepted content types. The pathname you upload to is baked into the token and cannot be rewritten server-side, so it has to carry your prefix or step 2 refuses it. Build it as prefix + filename. |
| 2. POST /v1/uploads | Body {"type":"blob.generate-client-token","payload":{"pathname":…,"clientPayload":…,"multipart":false}}. clientPayload is a JSON string {"contentType":"image/png"} — it picks the size cap and locks the token to that one type; omit it and the type is inferred from the file extension. Answers {"type":…,"clientToken":"vercel_blob_client_<store>_<jwt>"}. |
| 3. PUT the bytes | PUT https://vercel.com/api/blob/?pathname=<urlencoded pathname> with the raw file as the body and headers authorization: Bearer <clientToken>, x-api-version: 12, x-vercel-blob-store-id: <store> (the 4th underscore-separated field of the token), x-vercel-blob-access: public and x-content-type. The reply carries url — use that, not the pathname you asked for: a random suffix is added. |
| 4. (nothing) | Blob storage then posts an upload-completed webhook back to /v1/uploads, signed with x-vercel-signature. Nothing for you to do — the file is already durable at the end of step 3, and a failed callback never fails an upload. |
import os, json, requests
BASE = "https://api.highlander.sh"
H = {"Authorization": f"Bearer {os.environ['HIGHLANDER_KEY']}"}
def upload(path, content_type):
# 1. your prefix. The pathname is baked into the token, so it must already be yours.
policy = requests.get(f"{BASE}/v1/uploads", headers=H).json()
pathname = policy["prefix"] + os.path.basename(path)
# 2. a client token, scoped to that one pathname, content type and size cap
tok = requests.post(f"{BASE}/v1/uploads", headers=H, json={
"type": "blob.generate-client-token",
"payload": {"pathname": pathname,
"clientPayload": json.dumps({"contentType": content_type}),
"multipart": False},
}).json()["clientToken"]
# 3. the bytes, straight to Vercel Blob -- they never touch highlander.sh
store_id = tok.split("_")[3]
with open(path, "rb") as f:
blob = requests.put(
"https://vercel.com/api/blob/", params={"pathname": pathname}, data=f,
headers={"authorization": f"Bearer {tok}",
"x-api-version": "12",
"x-vercel-blob-store-id": store_id,
"x-vercel-blob-access": "public",
"x-content-type": content_type},
).json()
return blob["url"] # NOT pathname: a random suffix is added
url = upload("portrait.png", "image/png")
requests.post(f"{BASE}/v1/generations", headers=H, json={
"prompt": "The painting comes alive; she turns and smiles.",
"first_frame_url": url, "frames": 124,
})- — Limits: images 20 MB (image/jpeg, image/png, image/webp), audio 50 MB (audio/mpeg, audio/wav, audio/mp4, audio/flac), video 256 MB (video/mp4, video/quicktime, video/webm).
- — Uploads are public but unguessable: the URL carries a random suffix, so anyone with the link can read it and nobody can guess it. The worker has to be able to fetch it, which is why it cannot be private. Do not upload anything that must stay secret.
- — Uploads are deleted after about 7 days. They are scratch space for conditioning a generation, not storage — keep your own copy.
CLI
hl is a single file with no dependencies. It reads your key from HIGHLANDER_KEY, uploads any local file you point it at (the three steps above), creates the generation, polls it, and writes the mp4.
curl -fsSL https://highlander.sh/hl -o hl && chmod +x hl
export HIGHLANDER_KEY=hl_live_...
./hl generate "Waves break over black volcanic rock at dusk."
./hl generate "The painting comes alive; she turns." --first-frame photo.jpg --seconds 5
./hl status 0f7c1e2a-... --wait --download
./hl list --limit 5
./hl health # modes, queue and phase; no key needed
./hl upload subject.jpg # a URL to reuse across runs--first-frame takes either a local path or a URL. --last-frame and --ref are still parsed but the current backend does not support them, so the request is refused before anything is charged.
Length is --seconds 5|10|15 (the 124 / 243 / 345 frame presets) or --frames N for the raw count; anything else is rounded to the nearest legal value and printed. --seed and --negative map to the request fields of the same name; --steps does too, and must be 5. The mp4 lands in hl-<id>.mp4 unless you pass -o; --no-wait submits and exits, and --json prints the raw response of any command. Ctrl-C stops watching, not the job — it prints the id to resume with.
GET /v1/generations/{id}
Poll every couple of seconds. status moves queued → running → succeeded | failed in roughly 27–30 s for a full-length clip: ~11.76 s to generate it, then the clip's own length again while it is captured from a real-time playout. Add a few seconds if the worker has to reopen its model session first.
pipeline_state and queue_ahead are what distinguish those cases while a job is still queued — latency_s is generation time only and excludes the playout and every kind of waiting.
{
"id": "0f7c1e2a-...",
"status": "queued",
"mode": "fl2v",
"pipeline_state": "generating",
"queue_ahead": 0,
"queue_depth": 1,
"latency_s": null,
"video": null,
"poll": "/v1/generations/0f7c1e2a-..."
}{
"id": "0f7c1e2a-...",
"status": "succeeded",
"mode": "t2v",
"prompt": "A traceur vaults a rooftop ledge...",
"frames": 345,
"steps": 5,
"seed": 1000,
"width": 1344,
"height": 768,
"duration_s": 14.375,
"first_frame_url": null,
"last_frame_url": null,
"references": null,
"pipeline_state": null,
"cost_usd": 0.22,
"refunded": false,
"latency_s": 11.76,
"realtime_factor": 0.8181,
"error": null,
"created_at": "2026-09-01T18:04:11.201Z",
"finished_at": "2026-09-01T18:04:24.707Z",
"queue_ahead": null,
"queue_depth": null,
"video": "/v1/generations/0f7c1e2a-.../video",
"poll": "/v1/generations/0f7c1e2a-..."
}GET /v1/generations/{id}/video
Streams the mp4 (1344x768, 24 fps, AAC mono audio generated in the same pass). Returns 409 until the job has succeeded.
Examples
import os, time, requests
BASE = "https://api.highlander.sh"
H = {"Authorization": f"Bearer {os.environ['HIGHLANDER_KEY']}"}
job = requests.post(f"{BASE}/v1/generations", headers=H, json={
"prompt": "A traceur vaults a rooftop ledge into a roll, tracking shot alongside.",
"frames": 345, # 14.375 s
"steps": 5,
}).json()
while True:
s = requests.get(f"{BASE}/v1/generations/{job['id']}", headers=H).json()
if s["status"] in ("succeeded", "failed"):
break
# pipeline_state says WHY it is still waiting -- "queued", "connecting", "generating"
print(s["status"], s.get("pipeline_state"), s.get("queue_ahead"))
time.sleep(2)
if s["status"] == "failed":
raise SystemExit(s["error"])
mp4 = requests.get(f"{BASE}/v1/generations/{job['id']}/video", headers=H).content
open("out.mp4", "wb").write(mp4)
print(f"{s['latency_s']}s render, {s['realtime_factor']}x realtime, ${s['cost_usd']}")const BASE = "https://api.highlander.sh";
const H = { Authorization: `Bearer ${process.env.HIGHLANDER_KEY}`,
"Content-Type": "application/json" };
const job = await fetch(`${BASE}/v1/generations`, {
method: "POST",
headers: H,
body: JSON.stringify({ prompt: "Waves break over black volcanic rock at dusk." }),
}).then((r) => r.json());
let status;
do {
await new Promise((r) => setTimeout(r, 2000));
status = await fetch(`${BASE}/v1/generations/${job.id}`, { headers: H })
.then((r) => r.json());
} while (status.status === "queued" || status.status === "running");
const mp4 = await fetch(`${BASE}/v1/generations/${job.id}/video`, { headers: H });
await Bun.write("out.mp4", await mp4.arrayBuffer());Errors
| 401 | unauthorized | Missing or revoked key. |
| 402 | insufficient_credits | The quote exceeds your balance. Nothing was queued. |
| 422 | invalid_request | Bad frame count, prompt too long, steps other than 5, last_frame_url, unsupported resolution. |
| 429 | too_many_active_jobs | You already have 2 generations in flight. |
| 503 | mode_unavailable | The pipeline this request needs (currently: subject references) is not offered on the current backend. Nothing was queued or charged; GET /v1/health reports per-mode availability. |
| 503 | inference_unavailable | Inference is switched off or the worker is unreachable. You are not charged. |
| 502 | upstream_error | The worker rejected the job. Any reservation is refunded. |
Errors are JSON with error and message. Anything that fails to produce a video refunds its reservation, so a retry loop cannot silently drain a balance.
Limits and status
- — 2 concurrent generations per account. The worker plays one clip at a time.
- — Maximum clip length 14.375 s (345 frames).
- —
GET /v1/healthis public and reports whether inference is enabled, whether a model session is open (phase), per-mode availability, and how deep both queues are. - —
GET /v1/config(authenticated) returns the deployed model configuration and your balance.
If we are at capacity, requests are routed to Reactor Inc.