Skip to content
Heimdallm
Marketplace/Documentation
Documentation · API reference

Video generation

POST https://api-staging.impossiblecarrot.cc/api/v1/videos
GET  https://api-staging.impossiblecarrot.cc/api/v1/videos/{jobId}
GET  https://api-staging.impossiblecarrot.cc/api/v1/videos/{jobId}/content?index=0
GET  https://api-staging.impossiblecarrot.cc/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
  }'
StatusWhat it means
pendingAccepted, not yet started.
in_progressRendering.
completedGenerated; the clip can be fetched until the job's window closes, or for 10 minutes after its first delivery.
failederror carries the reason.
cancelledStopped before it produced one.
expiredThe 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

FieldTypeNotes
modelstringRequired.
promptstringOptional. A model that animates a supplied frame needs none.
durationnumberSeconds, including fractional frame-grid durations, matched against max_seconds.
resolutionstringOne of the published resolution tiers.
aspect_ratiostringOne of the published aspect ratios.
sizestringWIDTHxHEIGHT for one frame — interchangeable with resolution and aspect_ratio.
seedintegerFixing it makes a generation reproducible on the same endpoint.
generate_audiobooleanGenerate sound (true) or silent video (false); endpoint-specific default when omitted.
frame_imagesarrayStills pinned to the clip's ends. See above.
input_referencesarrayConditioning media. See above.
role (per item)stringWhat a conditioning item is for. Omitted, it is reference. Only declared roles route.
callback_urlstringHTTPS only. See Webhooks.
providerobjectProvider 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.

DiscoverFind your next modelModel libraryAll models, versions, prices and capabilitiesBenchmarksMeasured quality, cost and time by serving configurationShowcaseImages, video and 3D made on the marketplaceProvidersCompare independent inference providersActivityMarketplace traffic and usageNetwork statusAvailability, latency and endpoint healthChatConversations, model settings and filesImage, video & 3DGenerate images, video and 3D assetsScene studioStage a shot in 3D, direct it and render it as a videoCoding playgroundBuild software with an AI agent on a machine of your ownOverview & routingIdentity, workspaces and routing preferencesCredits & billingBalance, deposits, ledger and reserved fundsAPI keysKeys scoped to workspaces, limits and expirationOrganizationsTeams, members and shared workspacesRequest historyRequests, receipts, sandbox sessions and account historyReferralsInvitations and referral earningsPrivacyData settings, exports and deletionDocumentationQuickstart, SDKs, pricing and guidesAPI referenceInteractive endpoint reference