Quick answer: submit a job, poll it, download from content_url. The response is wrapped as {object: "video.job", job: {...}} with snake_case fields, credits_reserved is a string of internal units (divide by 100 for credits), and the REST route takes no idempotency key, so a dropped network can bill you twice.
The API for this model is small: submit a job, poll it, download the file. The reason people get stuck is not the shape of the calls — it is the details that differ between the marketing page and the contract, so this post is the contract, with the response shape as returned rather than as illustrated.
Submit
POST https://api.callirra.com/v1/videos
Authorization: Bearer sk-cal-...
Content-Type: application/json{
"model": "minimax-h3",
"mode": "image_to_video",
"prompt": "Slow dolly right at counter height; the light shifts to a warm rim; no cuts.",
"resolution": "768P",
"duration_seconds": 5,
"aspect_ratio": "16:9",
"frame_images": ["https://example.com/product-01.jpg"]
}Field notes that are worth the two minutes:
modeistext_to_video,image_to_videoorvideo_to_video. If you omit it, the route assumes text-to-video.duration_secondsis the current name;durationis accepted as an alias, so older examples still run.resolutionis matched case-insensitively on the way in (768pand768Pboth land on the 768P tier).frame_imagesandinput_referencesare arrays of URLs.generate_audiois a hint, not a switch: the output track is kept either way.
The response is wrapped
What comes back is not the job at the top level:
{
"object": "video.job",
"job": {
"id": "vid_...",
"model": "minimax-h3",
"status": "queued",
"resolution": "768P",
"duration_seconds": 5,
"credits_reserved": "1000",
"error_code": null,
"content_url": null
}
}Every field in that view is snake_case, including duration_seconds, credits_reserved and content_url. If you copy a request example from somewhere else with camelCase in the response, that is where it breaks.
credits_reserved is a string of internal units. Divide by 100 for public credits — "1000" is 10 credits, which is five seconds at 768P. Treat it as an opaque string; large reservations are why it is not a JSON number.
Poll
GET https://api.callirra.com/v1/videos/:idSame envelope, with the job filled in as it progresses: queued → running → completed, or one of failed, cancelled, expired. When it completes, content_url is a short-lived signed URL to the file, and config carries the effective parameters — useful for logging what was actually run, rather than what you think you sent.
When something fails, read error_code and then failure_reason: the code is the category the pipeline acted on, and the reason is the translatable detail, with failure_subject naming which attached file it concerned. That pair is what a user-facing message should be built from — not the raw upstream string.
let state;
for (let i = 0; i < 120; i += 1) {
const r = await fetch(`https://api.callirra.com/v1/videos/${job.id}`, {
headers: { authorization: `Bearer ${process.env.CALLIRRA_API_KEY}` },
});
state = (await r.json()).job;
if (['completed', 'failed', 'cancelled', 'expired'].includes(state.status)) break;
await new Promise((done) => setTimeout(done, 5000));
}Five seconds between polls is fine; these are minute-scale jobs, not sub-second ones. For a long-lived pipeline, prefer the binary route — GET /v1/videos/:id/content streams the MP4 with your key attached, so you do not have to chase a signed URL that may expire before your retry logic gets to it.
The five failures worth handling
| Code | What happened | What to do |
|---|---|---|
content_policy | the provider's safety check rejected the output | do not retry the same prompt; the reservation is released |
invalid_request_error | HTTP 400 before job creation — bad enum or unsupported combination | fix the payload; no job was reserved |
upstream_error | upstream submission or generation failed after job creation | inspect failure_reason; correct rejected input before retrying |
job_timeout | the job reached status: expired without completing | reconcile the terminal job before resubmitting |
cancelled | you cancelled it, or your session did | reservation returned |
The distinction that saves the most support tickets: invalid combinations are rejected before job creation with HTTP 400 and invalid_request_error. A 21:9 request at 1080P, or a two-second request at 2K, comes back this way, and the message names the constraint. upstream_error belongs to an existing failed job and is not a synonym for input validation.
Idempotency, honestly
The REST video route does not take a requestId. If your network drops after submit, you may create a second job and pay twice. Guard that on your side: write the job id you received before you do anything else with the response, and reconcile by listing your recent jobs with GET /v1/videos if you are unsure.
The MCP create_video tool has the same limitation: it accepts no caller requestId and does not automatically retry creation. If a bounded wait times out after returning a job id, resume that job with get_task; if submission itself is uncertain, reconcile recent jobs before creating another one.
CLI and MCP
The same operations exist as terminal commands and as MCP tools, against the same key and the same credit pool: submit, follow, download. Nothing about H3 is special-cased there; what changes is that an agent can call it without you writing a poll loop.
FAQ
Why is credits_reserved a string? It holds internal units and large reservations do not fit comfortably in a JSON number. Divide by 100 for public credits.
What does upstream_error mean? Upstream submission or generation failed after a job was created. Inspect failure_reason before retrying. Unsupported tier combinations instead return HTTP 400 with invalid_request_error before job creation.
How do I avoid paying twice after a network drop? Neither REST video creation nor MCP create_video accepts a caller requestId. Save any returned job id and resume it; reconcile recent jobs before repeating an uncertain submission.
Where this runs
The video contract is documented at https://callirra.com/api, including the route families and the response envelope, and the H3-specific tier matrix — which combinations are legal — is on https://callirra.com/minimax-h3.
I work on Callirra, which is why I can tell you the response is wrapped rather than flat: the marketing page's example and the route disagree, and the route wins. If you hit a payload that the matrix should accept and the API rejects, the payload plus the message is enough for me to trace it.