Video generation
POST https://api-staging.heimdallm.com/api/v1/videos
GET https://api-staging.heimdallm.com/api/v1/videos/{jobId}
GET https://api-staging.heimdallm.com/api/v1/videos/{jobId}/content?index=0
GET https://api-staging.heimdallm.com/api/v1/videos/models
Requires a bearer API key. Use a model whose output modality is video —
filter the model list or look for "output_modalities": ["video"] in the
catalogue.
Jobs: submit, poll, download
A clip takes longer to render than a request can be held open, so video generation is a job rather
than a reply. POST /videos answers 202 with { id, polling_url, status } the moment the job is
accepted; nothing is returned inline. Read polling_url until status is terminal, then fetch the
clip from unsigned_urls, or name a callback_url and skip the polling loop entirely.
prompt is optional. A model that animates a supplied frame is given the picture instead of the
words, and one that needs a prompt refuses a request without one. Geometry has two interchangeable
spellings: resolution with aspect_ratio, or size as WIDTHxHEIGHT.
curl ${API_BASE_URL}/videos \
-H "Authorization: Bearer $HEIMDALL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "lightricks/ltx-2.5",
"prompt": "A paper boat drifting across a rain puddle, reflections of neon signs",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
"generate_audio": true
}'
| Status | What it means |
|---|---|
pending | Accepted, not yet started. |
in_progress | Rendering. |
completed | Generated; the clip can be fetched until the job's window closes, or for 10 minutes after its first delivery. |
failed | error carries the reason. |
cancelled | Stopped before it produced one. |
expired | The delivery window closed with nothing fetched from the job. |
A job can return from in_progress to pending when the marketplace interrupts its render before
anything was charged for it, as a deployment replacing a gateway does. It then starts again from the
beginning on another gateway, and only the render that completes is charged. A job is charged for at most one
render. A render that was charged and never stored its result — interrupted after the endpoint's reply
was selected for billing, or ended by a gateway that stopped before recording it — is not tried
again, since that would charge twice: the job fails with fault: "marketplace" and carries the
usage it was charged.
A member appears once there is something to say: generation_id once the job has been processed,
unsigned_urls once there is content to fetch, clip_durations_seconds beside it naming what each
clip actually came to, usage once it was charged (on completion, or on the marketplace failure above), error once it failed, and fault only where that failure was the
marketplace's own rather than the endpoint's. An absent member is a fact
not yet established rather than an empty one, and usage.cost is null where the units consumed carry
no declared price — recorded unpriced and flagged, never reported as free.
{
"id": "vjob_1f9c0e2b7a4d4c6f9c0e2b7a4d4c6f9c",
"polling_url": "/api/v1/videos/vjob_1f9c0e2b7a4d4c6f9c0e2b7a4d4c6f9c",
"status": "completed",
"generation_id": "gen_01J9...",
"unsigned_urls": ["/api/v1/videos/vjob_1f9c0e2b7a4d4c6f9c0e2b7a4d4c6f9c/content?index=0"],
"clip_durations_seconds": [4],
"usage": { "cost": 0.28 }
}
Delivered clips stay 10 minutes, and undelivered ones stay until the window closes
The creation workspace keeps accepted job identifiers in this browser. After a reload, select an authorized API key and choose Resume to poll the saved job and collect its available clip. Resuming sends no new generation request. A download that breaks off resumes from the bytes already received. Browser storage limits and the asset expiry window still apply.
A download that starts at the first byte and is written through the last — a whole file, or
Range: bytes=0- — is the clip's delivery. Delivery does not remove the clip at once: it stays
available, in whole or by range, for 10 minutes from its first delivery, and a download started
after that answers 410, so keep what the download gave you. Two downloads under way at once both
complete. Nothing cuts a download short: the clip is never removed while a download of it is still
running, so a slow connection finishes whatever windows close meanwhile. A download that breaks off
leaves the clip in place: send Range: bytes=N-, where N is the number of bytes you already
hold, and append what comes back. One byte range is honoured, in the forms bytes=first-last,
bytes=first- and bytes=-count, and answers 206; a range that starts past the end answers 416.
A range that starts after the first byte is not a delivery, so a resumed download or the segments
of a parallel one leave the clip in place until one response carries it from the first byte to the
last, or its job's window closes. A Range header in any other form is ignored and the whole file is
sent. A HEAD request reports the length and delivers nothing.
A clip never delivered stays available until its job's window closes, 24 hours after
the render finished, and then answers 410 and is removed. A job nobody fetched anything from then
becomes expired, and its callback is told; a job fetched from, even in part, stays completed and
is told nothing more, since its content was claimed. A job that has not completed answers 409,
which is a different answer from content that is gone.
Between them those two rules are the whole retention story: nothing generated on this rail outlives its expiry window, or the 10 minutes after its first delivery, whichever comes first, except for as long as a download already under way is still reading it. The job row itself survives, because what you were charged stays answerable after the clip has gone. That is what lets a zero-data-retention provider serve this rail and stay zero-data-retention.
Webhooks
Name callback_url (HTTPS only) and skip the polling loop: the marketplace posts the job document —
the same shape GET /videos/{jobId} answers, wrapped in { id, type, created, data } — the moment the
job reaches completed or expired. type is video.generation.completed or
video.generation.expired, and each fires at most once per job.
{
"id": "evt_3f1c...",
"type": "video.generation.completed",
"created": 1755400420,
"data": {
"id": "vjob_1f9c0e2b7a4d4c6f9c0e2b7a4d4c6f9c",
"polling_url": "/api/v1/videos/vjob_1f9c0e2b7a4d4c6f9c0e2b7a4d4c6f9c",
"status": "completed",
"generation_id": "gen_01J9...",
"unsigned_urls": ["/api/v1/videos/vjob_1f9c0e2b7a4d4c6f9c0e2b7a4d4c6f9c/content?index=0"],
"clip_durations_seconds": [4],
"usage": { "cost": 0.28 }
}
}
The POST carries a Heimdall-Signature header of t=<unix seconds>,v1=<hex HMAC-SHA256>, computed
over the string <t>.<raw request body> with a secret minted the first time your account submits a
job naming a callback. Verify it the way a Stripe signature header is verified:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, toleranceMs = 300_000) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const signedAtMs = Number(parts.t) * 1000;
if (!Number.isFinite(signedAtMs) || Math.abs(Date.now() - signedAtMs) > toleranceMs) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`, 'utf8').digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(parts.v1 ?? '', 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
Sign against the exact bytes received, never a re-serialized parse of them. A delivery that does not
receive a 2xx is retried with exponential backoff for up to eight attempts before it stops trying;
GET /videos/{jobId} always has the answer a delivery could not make land, so a callback is a
convenience over polling rather than the only way to learn a job's outcome.
What a model will take
GET /videos/models publishes supported_resolutions, supported_aspect_ratios, supported_sizes,
supported_durations, supported_frame_images, supported_reference_roles, generate_audio, seed, pricing_skus and
allowed_passthrough_parameters. Each supported_* member is the union across the model's live
endpoints, so it says what can be asked for somewhere rather than what one endpoint keeps, and it is
null where no endpoint declares it — undeclared, not empty.
pricing_skus gives the per-second rate as a decimal string under duration_seconds, and under that
name suffixed by a resolution for each tier priced separately:
{
"duration_seconds_480p": "0.068",
"duration_seconds_720p": "0.14",
"duration_seconds_1080p": "0.28"
}
A tier is the same meter at a different rate, never a second unit. A listing carrying only suffixed names declares no untiered rate, which is a partial declaration rather than a free tier.
A clip is frames, and a frame is a picture
Everything image generation says about a size says the same thing here about a
frame. An endpoint publishes what it renders as video_envelope in the catalogue,
and frame inside it is an image_envelope read by exactly those rules: a pixel budget, an edge
range, and the grid both edges sit on.
What a clip adds is the third axis: max_seconds, the longest clip an endpoint renders; fps, the
frame rates it serves as a list rather than a range, because a video model is trained at particular
rates and interpolating between them is a different model; and frame_step, the temporal grid a
frame count sits on, so the counts an endpoint can build are frame_step × n + 1. duration on the
job wire accepts fractional seconds on the endpoint’s frame grid; what actually renders is measured back as clip_durations_seconds once
the job completes, since a length off an endpoint's own grid is not silently stretched to match.
Frame images and reference assets
frame_images pins a still to one end of the clip: each entry carries frame_type of first_frame
or last_frame, so an array of one still names which end it is. input_references conditions the
generation on uploaded media without being a frame of it — an image_url, audio_url or video_url
part. Every asset on this route is inline base64: the marketplace fetches nothing on your behalf, so a
URL it would have to reach is answered 400 where you can still act on it. Each asset is held to 20
MiB of decoded bytes on its own where the body is parsed, and the two lists are folded into one before
dispatch and budgeted together at that same 20 MiB across both, so a pair that each fit and together
do not is answered 400. A request whose body is larger than this route accepts is answered 413 by
size, before either budget is read. An
endpoint must declare the requested input modalities and input_references on its video output; its
reference_inputs.video entry in the model's endpoint listing publishes accepted MIME types and the
reference count limit.
Each input_references item may carry a role saying what it is for. reference conditions the
clip however the engine conditions on that kind, and is what an item without a role is. canny,
depth, normal, openpose and segmentation are control passes the render keeps the structure
of, on a still or on a clip, each named for the preprocessor that drew it; edges, pose and seg
are accepted as the same three passes. A pinned still is never a role here: it is a frame_images item, and the
endpoint is handed it under first_frame or last_frame, which are the two roles an endpoint
declares for the ends of a clip. A reference ask no endpoint of the model can
serve — a kind, a role, a count, or a combination of control passes none of them loads together — is
answered 400 at submission, naming what those endpoints do accept. reference_inputs.video.roles on
the endpoint listing says which roles each input kind is taken under and how many items of each one
request may carry, reference_inputs.video.control_sets, where it is published, which control passes
may travel together, and supported_reference_roles on GET /videos/models is the union of the
roles across the model's endpoints, keyed per input kind as the endpoint listing keys them —
{ "image": ["first_frame"], "video": ["reference", "depth"] }, rather than one flat array.
{
"model": "lightricks/ltx-2.5",
"prompt": "The camera slowly moves around the subject.",
"frame_images": [
{
"type": "image_url",
"image_url": { "url": "data:image/png;base64,BASE64_BYTES" },
"frame_type": "first_frame"
}
],
"input_references": [
{
"type": "video_url",
"video_url": { "url": "data:video/mp4;base64,BASE64_BYTES" },
"role": "depth"
}
],
"generate_audio": true
}
The job dispatches with both lists attached, exactly as /image's Animate action does when it
submits a job on your behalf: a frame image reaches the endpoint as a reference in the role its
frame_type names, ahead of the conditioning references, and the two lists together carry at most
three items and at most 20 MiB of decoded bytes. A submission over either bound is answered 400,
naming the bound and, for the count, the number the job carries; one whose body is larger than the
route accepts is answered 413 by size before either bound is read.
Generated sound
generate_audio: true requests a soundtrack generated with the clip from the same prompt; false
requests silent video. Either explicit value routes only to an endpoint declaring and admitting that
choice. It is never silently dropped. If no eligible endpoint remains, the job fails with a reason
naming the mismatch.
There is no universal API default. Omission uses the selected endpoint's declared default, when one
exists. The catalogue's video_generation.generate_audio publishes supported_values and default
only where known. video_envelope.audio describes capability, not the request's selected value.
ImpCC generates sound by default and supports explicit silent output. Its bundled per-second tariff is the total video rate for either choice; no extra audio charge is added. Other providers can declare audio-dependent tariffs, selected and frozen with the request.
Request body
| Field | Type | Notes |
|---|---|---|
model | string | Required. |
prompt | string | Optional. A model that animates a supplied frame needs none. |
duration | number | Seconds, including fractional frame-grid durations, matched against max_seconds. |
resolution | string | One of the published resolution tiers. |
aspect_ratio | string | One of the published aspect ratios. |
size | string | WIDTHxHEIGHT for one frame — interchangeable with resolution and aspect_ratio. |
seed | integer | Fixing it makes a generation reproducible on the same endpoint. |
generate_audio | boolean | Generate sound (true) or silent video (false); endpoint-specific default when omitted. |
frame_images | array | Stills pinned to the clip's ends. See above. |
input_references | array | Conditioning media. See above. |
role (per item) | string | What a conditioning item is for. Omitted, it is reference. Only declared roles route. |
callback_url | string | HTTPS only. See Webhooks. |
provider | object | Provider preferences. |
Endpoints differ in which geometry and options they honour: each publishes its own
supported_parameters in the catalogue, and that list is the promise.
What you are charged
The endpoint's output-video tariff charges per second of delivered video, with any declared
conditions for supported settings. The public video_second price is USD per output second; it is
independent of render wall time. A job that takes a minute to render can produce four seconds of
billable video.
For example, a 4-second clip at $0.07 per second bills $0.28. The selected rate is frozen for the request and used for reservation, settlement and recovery. Resolution-dependent rates require explicit declared conditions; a pixel size is not silently mapped to a resolution-tier name. A tariff without a supported measurement or finite reservation bound is refused before dispatch.
clip_durations_seconds describes what rendered; it is the measured quantity billing settles from,
not a request echo. A delivered clip outside the admitted request fails the job rather than creating
an unexpected charge.
Errors
The error model is the same as everywhere. Submission fails with 400 when the body
does not parse or a member is outside the range the API accepts — a resolution, aspect ratio or frame
role outside the wire's own enum, a size that is not WIDTHxHEIGHT, or a callback_url that is not
HTTPS. It also fails with 400 when the assets it names cannot be served as asked: frame_images
and input_references coming to more than three items together, or a combination of control roles no
endpoint of the model loads in one render. Both are read from the body, so neither waits for a render.
A model nobody serves an active video endpoint for answers 404 at submission, before any work
begins.
Everything a render itself can fail on — no eligible endpoint for the requested geometry, an off-grid
length, an endpoint that stopped answering — surfaces as status: "failed" with error naming the
reason, on the next poll or the video.generation.completed-shaped webhook that never arrives instead
of a video.generation.expired one. video_envelope in the catalogue has the
bounds an endpoint renders within.
Select provider adapters
An endpoint's adapters.video catalogue lists provider-hosted LoRAs, immutable weight revisions,
compatible base models, supported strengths, required references and availability. Discover these
through the model's endpoint catalogue. Select at most four entries with
provider.options[provider_slug].adapters, each containing id, revision and strength.
Pin the provider with provider.only and allow_fallbacks: false. A stale revision, unsupported
combination or missing required reference is refused before dispatch. No adapter weight upload
is needed.
Adapters marked included use the generation tariff. conditional entries change that tariff;
they are not necessarily free additions. Endpoint pricing_skus publishes the exact decimal rates
and their conditions. Rendering can take several minutes, especially with control inputs or a
second refinement stage. The provider's published range is a rate range; duration and other billed
usage determine the total.
The CLI can submit, wait and download in one operation:
heimdall videos create --input request.json --out scene.mp4
The request file accepts the full video job body, including references and provider adapter options.
--wait waits without downloading; omitting both flags returns the accepted job immediately.
heimdall videos show JOB_ID --out scene.mp4 resumes an existing job without submitting again.
An existing output file is refused before generation begins. A timeout leaves the job running and
prints its ID for resumption. heimdall videos models lists generation capabilities.