Skip to main content
MiniMax H3

Submit, poll, download: the H3 job contract on one page

A support thread last month turned on one field: credits_reserved: "2000".

The developer read it as two thousand credits and assumed they had been charged a hundred times the price. It is an internal-unit integer rendered as a string — divide by 100 and it is twenty credits, which is exactly ten seconds at 768P. The API was right; the shape was surprising.

That is the pattern with asynchronous video APIs. The hard part is rarely the prompt. It is the contract around the job.

The envelope

Every response is wrapped the same way, with snake_case fields:

{
  "object": "video.job",
  "job": {
    "id": "vid_9f3c…",
    "model": "minimax-h3",
    "status": "queued",
    "resolution": "768P",
    "duration_seconds": 10,
    "credits_reserved": "2000",
    "error_code": null,
    "content_url": null
  }
}

Create

curl https://api.callirra.com/v1/videos \
  -H "Authorization: Bearer $CALLIRRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "minimax-h3",
    "prompt": "slow dolly through a rain-lit night market, neon reflections",
    "resolution": "768P",
    "duration_seconds": 10,
    "aspect_ratio": "16:9"
  }'

Poll

Six statuses, and one decision each:

StatusMeaningWhat to do
queuedaccepted, not startedkeep polling with backoff
runninggeneratingkeep polling
completedfile existsdownload and store
failedrender failedread error_code; reservation released
cancelledcancelledreservation released
expiredwindow passedreservation released
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function waitForJob(id, headers) {
  let delay = 2000;
  for (let attempt = 0; attempt < 60; attempt += 1) {
    const res = await fetch(`https://api.callirra.com/v1/videos/${id}`, { headers });
    const { job } = await res.json();
    if (['completed', 'failed', 'cancelled', 'expired'].includes(job.status)) return job;
    await sleep(delay);
    delay = Math.min(delay * 1.5, 15000);
  }
  throw new Error(`job ${id} still running after ~10 minutes`);
}

A fixed sleep is the fastest way to get rate-limited for no reason: a ten-second 768P render and a fifteen-second 2K render do not finish on the same schedule.

Download

GET /v1/videos/:id/content streams the MP4 with authentication. job.content_url is a short-lived signed URL — fine for a browser, wrong for a database. Copy the bytes somewhere you control and treat the signed URL as disposable.

Cancel

POST /v1/videos/:id/cancel cancels a queued or running job and releases the reservation. It is the call behind a "stop" button, and it is the reason a user who changes their mind at minute two is not billed for a file they never wanted.

The idempotency trap

Video creation does not accept a caller-supplied request ID, so a retried POST can create a second job. The safe pattern:

  1. Persist the accepted job.id the moment it returns.
  2. On a network error, list your jobs and look for the one you created rather than posting again.
  3. Only post a new job when you can prove none exists.

Other surfaces on the platform accept a caller requestId for exactly this purpose. Video creation does not, and pretending otherwise produces duplicate charges that look like a billing bug.

Where it goes wrong

Treating create as the deliverable. It returns a job, not a file.

Parsing `credits_reserved` as credits. Internal units; divide by 100.

Storing `content_url`. It expires; store the file.

Retrying a 4xx. Validation errors are deterministic — a retry spends quota and returns the same error.

FAQ

Is there an outbound webhook for completion? Polling is the documented contract for video jobs.

Does a failed job cost credits? No. failed, cancelled and expired release the reservation.

Can I download later? Yes, through GET /v1/videos/:id/content. The signed URL is the part that expires.

Do MCP and CLI use the same contract? Yes — same job, same statuses, same download path.

The endpoint reference is in Callirra's API docs. If a response shape here does not match what you receive, the request id and the exact envelope are the two things that let me fix the page.