Renders
A render is a job, not a request. POST /v1/renders returns as soon as the job
is accepted; the pixels happen elsewhere.
Create a render
POST /v1/renders
Authorization: Bearer pm_live_…
Content-Type: application/json
Idempotency-Key: order-4711-render{
"templateId": "tpl_7f8a9b",
"inputs": {
"headline": "Launch Special 2026",
"accentColor": "#2988ff",
"character": "https://cdn.example.com/photo.jpg"
},
"output": { "format": "mp4", "width": 1080, "height": 1920, "fps": 30 },
"callbackUrl": "https://hooks.example.com/premation"
}| Field | Required | Notes |
|---|---|---|
templateId | yes | Must belong to your account |
inputs | yes | Keys must be declared ids; every required field must be present |
output | no | Defaults to the template’s own dimensions and fps |
callbackUrl | no | See Webhooks |
Response:
{ "jobId": "clx9zz110000", "status": "queued" }Scope: renders:write.
Output overrides
| Field | Allowed |
|---|---|
format | mp4 only |
width, height | Integers, 64–4096, and at most 16 megapixels together |
fps | Integer, 1–120 |
Anything outside those answers 400. Omit output entirely and the
template’s published dimensions are used.
MP4 is the only output format over the API. The desktop exporter’s other the desktop’s formats — WebM with alpha, ProRes 4444, PNG sequence, Lottie, SVG, GIF — are not exposed here. MP4 also means no alpha channel: transparency is flattened onto the composition background. Audio is not muxed by the hosted renderer yet.
Idempotency
Pass Idempotency-Key on POST /v1/renders and a repeat of the same key
returns the original job instead of queueing a second render.
Idempotency-Key: order-4711-renderThe key must be 8–128 characters from A-Z a-z 0-9 . _ : -; anything else is a
400. Keys are scoped to your account, so they never collide with another
customer’s.
Use it. A retry-on-timeout in n8n or a re-run of a CI job is exactly the case
this exists for — without a key, a network blip that hides a 201 costs you a
duplicate render and duplicate quota. Derive the key from something stable in
your domain (an order id, a row id), not from a timestamp.
Poll a job
GET /v1/renders/{jobId}{
"jobId": "clx9zz110000",
"status": "completed",
"progress": 100,
"videoUrl": "https://res.cloudinary.com/…/out.mp4",
"error": null,
"createdAt": "2026-08-27T09:00:00.000Z"
}Scope: renders:read. A job id belonging to another account answers 404.
Lifecycle
queued ──▶ processing ──▶ completed
├──▶ failed
└──▶ cancelled| Status | Meaning |
|---|---|
queued | Accepted and enqueued; no worker has picked it up |
processing | A render worker is executing it; progress moves |
completed | videoUrl is populated |
failed | error explains why, sanitised for public consumption |
cancelled | Cancelled by you |
completed, failed and cancelled are terminal — a job never leaves them.
A render is not instant, and a long or heavy composition can take longer than its own duration. Budget generously: poll on a 3–5 second interval rather than a tight loop, or use a webhook and stop polling altogether.
List jobs
GET /v1/renders?limit=50&offset=0{
"items": [
{ "jobId": "…", "status": "completed", "progress": 100, "videoUrl": "…", "error": null, "createdAt": "…" }
],
"total": 128,
"limit": 50,
"offset": 0
}Newest first. limit defaults to 50, capped at 100. Scope: renders:read.
Cancel a job
POST /v1/renders/{jobId}/cancelCancelling releases the render minutes that were reserved when the job was created. A job that is already terminal is returned as-is rather than erroring — a cancel that races a completion is not your fault, and you get back the status that actually won:
{ "jobId": "clx9zz110000", "status": "completed" }A render already executing at the worker finishes there; its result is
discarded. If the job carried a callbackUrl, the cancellation is delivered to
it. Scope: renders:write.
Download
GET /v1/renders/{jobId}/download
→ 302 Location: https://res.cloudinary.com/…/out.mp4A redirect, not a byte stream — the file lives on a CDN, and proxying video through the API would tie up a request thread for the length of a download. Most HTTP clients follow it by default, n8n’s HTTP Request node included.
| Job state | Answer |
|---|---|
completed | 302 to the file |
queued / processing | 409, with the current status in the body |
failed / cancelled | 409, with the reason |
{
"message": "Render is still processing. Poll GET /v1/renders/:id or use callbackUrl.",
"status": "processing"
}Scope: renders:read.
Rate limiting
Render creation is limited to 60 per minute per account, independently of
your monthly quota. Over it you get 429 with a Retry-After hint in the
body:
{
"code": "rate_limit_exceeded",
"retryAfterSeconds": 60,
"message": "Too many render requests. Try again shortly."
}429 rather than 403 is deliberate: HTTP clients and n8n back off on 429 and treat 403 as “your credentials are wrong”, which used to kill workflows that were merely early.
Failure modes worth handling
| Status | When |
|---|---|
| 400 | Unknown input key, wrong value type, missing required input, bad output dimensions, malformed Idempotency-Key |
| 403 | Plan lacks API access, monthly render minutes or API requests exhausted |
| 404 | Template or job id not on your account |
| 409 | Downloaded a job that is not completed |
| 429 | More than 60 renders in a minute |
A 400 for bad inputs enumerates every offending field at once:
{
"statusCode": 400,
"message": "Invalid inputs.",
"errors": [
{ "field": "headline", "message": "Input \"headline\" must be a string." },
{ "field": "logo", "message": "Unknown input \"logo\"." }
]
}