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 nine 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.
Renders are CPU-bound and run on SwiftShader, so 1080p is often slower than realtime. 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\"." }
]
}