Errors
Every error carries an id — a stable, machine-readable code. Branch on it. Never match on message, which is prose and can change at any time.
{
"id": "template_not_found",
"message": "Template UUID '5f0e…' not found"
}| Field | Present | Description |
|---|---|---|
id | always | The code to branch on. |
message | always | Human-readable explanation. Not stable — do not parse it. |
errors | only when there is field-level detail | Which fields were rejected: a non-empty flat array of {path, code, message}. Never an object, never empty — absent simply means the error is not about a particular field. |
This is the shape of every error from every endpoint of this API, at every status. There is no second envelope to detect.
Two rules keep your handler working as the API grows:
- An
idyou do not recognise is not an error in your code. Codes are added over time; treat an unknown one as generic and fall back tomessage. - Ignore
errors[]keys you do not know. Entries always carrypath,codeandmessage, and may carry more (expected,received).
Migrating a client written before v2026-08-17
Three changes to make, once:
| Before | Now |
|---|---|
err.errors.id | err.id — the code moved to the top level, and is now on every error rather than some. The value is unchanged. |
err.errors was a nested field map | A flat array of {path, code, message}, with object keys dotted and array indices bracketed (formats[0].layers). |
message began with the HTTP status ("404 Design … not found") | The number is gone ("Design … not found"). It was never stable enough to parse — branch on id. |
Framework-level failures also answer real codes now: method_not_allowed for a 405, invalid_json for a malformed body, internal_error for an unhandled exception. All three previously answered the placeholder id: "error".
Three 404 codes that shipped with a literal space in them are spelled correctly: workspace_template_not_found, project_not_found, duplication_request_not_found.
Every error id
Grouped by what you should do about it, which is the only thing that changes your code.
The design import validates payloads field by field and has a larger vocabulary of its own — missing_required, unknown_field, out_of_range, unknown_enum_value and the rest, each attached to the rule that produced it. Those live in Import status, warnings and errors and are not repeated here.
Fix the request — 400, 405, 406, 415
id | Status | Trigger | Fix |
|---|---|---|---|
invalid_payload | 400 | A field failed validation. | Read errors[] — each entry names the field in path. |
invalid_json | 400 | The request body is not parseable JSON. | Check the serialisation on your side. |
method_not_allowed | 405 | The route exists but does not accept this HTTP method. | Check the method against the reference. |
not_acceptable | 406 | The Accept header does not allow a JSON response. | Send Accept: application/json, or no Accept header at all. |
unsupported_media_type | 415 | Content-Type is not application/json. | Set the header. Not to be confused with unsupported_for_type, which is about design types. |
template_not_static | 400 | Synchronous generation called on an animated, printer or printer_multipage design. | Use asynchronous generation. |
invalid_design_type | 400 | The multi-page PDF endpoint called on a design that is not printer_multipage. | Use the standard async endpoint. |
invalid_filetype | 400 | image_file_type is not produced by this design type — e.g. mp4 on a static design. | See the output types for static and animated. |
more_than_one_format | 400 | original_visual_id sent while generating more than one format. | Versioning replaces one visual, so target exactly one format. |
cannot_build_banner | 400 | The render engine refused the content — most often text that cannot fit its layer even at the smallest allowed size. message names the element. | Shorten the text, enlarge the layer, or lower min_font_size. |
image_fetching_error | 400 | A Google Drive image_url could not be fetched. Only Drive links are pre-checked this way; other unreachable assets fail later in the render, as cannot_build_banner. | Share the file so anyone with the link can view it, or host the image somewhere public. |
project_already_exists | 400 | POST /projects with a name already taken in this workspace. The comparison uses the trimmed name, so " Summer " collides with "Summer". | Reuse the existing project, or pick another name. See Projects. |
template_not_active | 400 | Minting a dynamic image for a design that is not in the CREATED state — still importing, or deleted. This endpoint only. | Wait for the import to reach DONE, or check the design still exists. |
Fix the identifier — 404
id | Trigger |
|---|---|
template_not_found | The design does not exist, was deleted, or belongs to another workspace. |
format_not_found | A template_format_name(s) entry is not a format of this design. |
visual_not_found | original_visual_id does not exist. |
not_related_to_same_template / not_related_to_same_format | original_visual_id belongs to a different design / format. |
generation_request_not_found | Unknown generation-request id when polling. |
duplication_request_not_found | Unknown duplication-request id when polling. |
project_not_found | The project_id does not exist in this workspace. |
workspace_template_not_found | The workspace template does not exist, or belongs to another workspace. |
endpoint_not_found | No such route — including a path parameter that is not a valid UUID. |
not_found | The addressed resource does not exist. The generic form, used where no more specific id applies. |
Resolve the conflict — 409
The resource exists but is in a state that refuses the operation. Retrying unchanged never helps.
id | Trigger | Fix |
|---|---|---|
previous_secret_still_active | A second signing-secret rotation inside the 24-hour overlap window, which would revoke the secret the first rotation is still serving. | Wait for the overlap to lapse, revoke it deliberately, or repeat with ?force=true. |
template_import_already_processed | PUT on a design import that has already left WAITING_FOR_VALIDATION. | The import is past the upload phase — poll its status instead. |
Wait, then retry — 410, 500
| Status | id | Trigger | Fix |
|---|---|---|---|
410 | generation_request_gone | The generation request expired. Requests are kept 7 days. | Generate again; store the result rather than re-polling later. |
410 | duplication_request_gone | The duplication request expired, on the same retention. | Duplicate again; read the result when it completes rather than later. |
500 | internal_error | An unexpected failure in this API. | Retry once. If it persists, contact [email protected] with the response. |
500 | internal_server_error | A service behind the API failed. | Same — retry once, then contact support. |
Rate limits and plan limits — 429
Three unrelated refusals answer 429, and only one of them is worth retrying. Branch on id, never on the message text.
id | Means | Retryable? | Fix |
|---|---|---|---|
request_rate_limited | Too fast for this route's tier. | Yes | Wait Retry-After seconds. A refused request still counts. |
rate_limit_exceeded | Out of credits — or the global 10 requests/second ceiling. | Only the ceiling case | Read message: back off for the ceiling, top up for credits. |
feature_not_in_plan | Your plan does not include the design type you are importing. Carries no errors[]: the payload is fine. | No | Upgrade. ?validate_only=true is refused too. |
Full breakdown, including which plan each design type needs, on Rate limits → Three different 429s.
Authentication — 401
id | Trigger |
|---|---|
unauthorized | The x-api-key header is missing or invalid. |
api_access_denied | The key is valid, but your plan does not include API access. |
There is no 403. A key that is valid but not entitled answers 401 api_access_denied.
Field-level detail
When an error names specific fields, they arrive in errors[] — the same array on every endpoint, including Design Import and detail that originates in the render engine, which is translated into this shape before you see it:
{
"id": "out_of_range",
"message": "font_size must be between 2 and 1000",
"errors": [
{ "path": "layers[2].properties.font_size", "code": "out_of_range",
"message": "font_size must be between 2 and 1000",
"expected": { "min": 2, "max": 1000 }, "received": 1500 }
]
}path locates the field: object keys are dotted, array indices bracketed (formats[0].width, elements.root.background_color).
When every entry shares one code, the top-level id is that code. When they differ, id is invalid_payload, meaning "read errors[]".
One thing that is not an error envelope: inside a completed async result, per-format failures appear as {template_format_name, reason} entries in the result's own errors[]. They report one format that did not render; the request itself succeeded.
Related
- Generate a single image
- Asynchronous generation
- Design import errors — every import code in detail
- Authentication
