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"
}| Field | Always present | Notes |
|---|---|---|
statusCode | yes | Matches the HTTP status |
message | yes | String, or an array of strings for validation failures |
error | yes | The status name |
requestId | yes | Log this. |
path | yes | The route that failed |
code | no | A machine-readable discriminator where one exists |
detail | no | Structured 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.
| Code | Status | Meaning |
|---|---|---|
session_required | 403 | An API key called a session-only route (any /v1/keys route) |
insufficient_scope | 403 | The key lacks the required scope — required names it |
upgrade_required | 403 | The plan does not include API access |
key_limit_reached | 403 | At the plan’s active-key cap — limit names it |
quota_exceeded | 403 | Monthly quota reached — kind, limit, used |
rate_limit_exceeded | 429 | Too many renders per minute — retryAfterSeconds |
unrenderable_assets | 400 | A template’s media cannot be fetched — layers names each one |
nothing_to_expose | 400 | An imported project has no media layers to drive |
upload_too_large | 413 | A file exceeds the plan’s maxUploadBytes — limit, bytes |
Statuses
| Status | What it means here | Retry? |
|---|---|---|
| 400 | Malformed body, unknown input key, wrong value type, missing required input, bad output dimensions, malformed Idempotency-Key | No — fix the request |
| 401 | Missing, unknown, revoked or expired credential | No |
| 403 | Plan, scope or quota | No — except a quota, which clears next period |
| 404 | Template or job not on your account, or the route is not deployed | No |
| 409 | Downloaded a render that is not completed | Yes, after it settles |
| 413 | The uploaded file is over the plan limit | No — send a smaller file |
| 429 | Rate limited | Yes, after retryAfterSeconds |
| 5xx | Server-side | Yes, 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:
| Body | Meaning |
|---|---|
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.