Skip to Content

Errors

One envelope

Every failure — validation, auth, quota, or an unhandled crash — comes back in the same shape:

{ "statusCode": 403, "message": "This API key does not have the renders:write scope.", "error": "Forbidden", "requestId": "b3f1c2a4", "path": "/api/v1/renders" }
FieldAlways presentNotes
statusCodeyesMatches the HTTP status
messageyesString, or an array of strings for validation failures
erroryesThe status name
requestIdyesLog this.
pathyesThe route that failed
codenoA machine-readable discriminator where one exists
detailnoStructured extras you are meant to act on

requestId is the useful one. It is the same id in the server’s log line for that request. Quoting it in a support message turns “a render failed yesterday” into an exact log entry.

Codes

Branch on code where it exists; it is stable, and message is not.

CodeStatusMeaning
session_required403An API key called a session-only route (any /v1/keys route)
insufficient_scope403The key lacks the required scope — required names it
upgrade_required403The plan does not include API access
key_limit_reached403At the plan’s active-key cap — limit names it
quota_exceeded403Monthly quota reached — kind, limit, used
rate_limit_exceeded429Too many renders per minute — retryAfterSeconds
unrenderable_assets400A template’s media cannot be fetched — layers names each one
nothing_to_expose400An imported project has no media layers to drive
upload_too_large413A file exceeds the plan’s maxUploadByteslimit, bytes

Statuses

StatusWhat it means hereRetry?
400Malformed body, unknown input key, wrong value type, missing required input, bad output dimensions, malformed Idempotency-KeyNo — fix the request
401Missing, unknown, revoked or expired credentialNo
403Plan, scope or quotaNo — except a quota, which clears next period
404Template or job not on your account, or the route is not deployedNo
409Downloaded a render that is not completedYes, after it settles
413The uploaded file is over the plan limitNo — send a smaller file
429Rate limitedYes, after retryAfterSeconds
5xxServer-sideYes, with backoff

404 is used for another account’s resources. A template or job id that exists but belongs to someone else answers 404, not 403 — the API will not confirm that an id exists to a caller who cannot read it. So a 404 on an id you believe is yours means you are holding a key for the wrong account.

Validation errors name every field

Input failures are enumerated, not reported one at a time, so a fix-and-retry loop converges in one round trip:

{ "statusCode": 400, "message": "Invalid inputs.", "errors": [ { "field": "headline", "message": "Input \"headline\" must be a string." }, { "field": "hero", "message": "Input \"hero\" must be a public http(s) URL. Host resolves to a private address." }, { "field": "logo", "message": "Unknown input \"logo\"." } ] }

Unknown input means the key is not declared on the template. Read GET /v1/templates/{id} and compare against inputs[].id.

Publishing and importing name the layer

The refusal a first template most often meets. Media whose source lives only on the author’s machine cannot be fetched by the render service, so a template built on it is rejected rather than rendering the layer as nothing:

{ "statusCode": 400, "code": "unrenderable_assets", "message": "Some layers have sources the render service cannot fetch, so they would render empty. Re-host them at a public URL, or expose them as required inputs.", "layers": [ { "layer": "Hero Image", "reason": "its footage was imported from local storage and was never uploaded, so only this machine can resolve it" } ] }

Two fixes, either works: re-host that artwork at a public URL and re-import, or expose the layer as a required input so your workflow supplies a URL on every render. Import does the second automatically — which is why an imported project rarely sees this, and a hand-authored one sometimes does.

This check is new. A template published before it existed can still contain unfetchable media and will still render that layer empty — re-publish or re-import it to find out which.

Upload errors

POST /v1/assets refuses early rather than storing something the renderer will choke on later:

BodyMeaning
upload_too_large (413)Over maxUploadBytes; the body names the limit and your size
“is not a renderable asset type” (400)The format is not one the renderer decodes
“was sent as …” (400)The declared content type contradicts the actual bytes

A generic application/octet-stream is not a contradiction — it reads as “no type given”, and the bytes decide. That is what most HTTP clients send.

What errors never contain

Server-side messages are sanitised before they reach you. A database failure answers a generic 500 — the underlying query, table and column names stay in the server log, keyed by the requestId you were given. If a 500 is reproducible, quote that id.

A retry policy that works

async function call(url, init, attempt = 0) { const res = await fetch(url, init) if (res.ok) return res.json() const body = await res.json().catch(() => ({})) // Retryable: rate limit and server-side failure. Nothing else. const retryable = res.status === 429 || res.status >= 500 if (!retryable || attempt >= 4) { throw new Error(`${res.status} ${body.code ?? ''} ${body.message ?? ''} (${body.requestId})`) } const wait = body.retryAfterSeconds ? body.retryAfterSeconds * 1000 : 2 ** attempt * 1000 await new Promise((r) => setTimeout(r, wait)) return call(url, init, attempt + 1) }

If you retry POST /v1/renders, send the same Idempotency-Key on every attempt. Otherwise a retry after a response you never saw queues a second render and bills you twice. See Renders.

Last updated on