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:
| Status | Meaning | What to do |
|---|---|---|
queued | accepted, not started | keep polling with backoff |
running | generating | keep polling |
completed | file exists | download and store |
failed | render failed | read error_code; reservation released |
cancelled | cancelled | reservation released |
expired | window passed | reservation 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:
- Persist the accepted
job.idthe moment it returns. - On a network error, list your jobs and look for the one you created rather than posting again.
- 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.