Authoring Templates
The public API can author templates, not only render them. The loop is fixed: validate a canvas design for free, render a charged preview that returns a render receipt, then create (or update) the template with that receipt. A design never reaches the database unless the exact same bytes rendered within the last hour, and the preview becomes the template’s thumbnail.
This is the surface the Zandovi MCP server uses for validate_design, preview_design, create_template, edit_template, delete_template and create_project; if you connect an AI assistant, the tools drive this loop for you and the receipt never leaves the server. Read on if you call the API yourself.
What a design is
Section titled “What a design is”A design is a canvasData JSON document: { "schemaVersion": "1.0", "viewport", "background", "elements" }, the same document the designer saves. The element field tables, fonts, icons and worked examples are served over HTTP at GET /api/v1/design-reference, over MCP by the get_design_reference and search_icons tools (the same knowledge pack), also published as the zandovi-template-authoring skill. The API validates every design server-side and is authoritative: a stale copy of the guidance only produces more 422 responses with hints, never a bad write.
The examples below use this design; save it as design.json:
{ "schemaVersion": "1.0", "viewport": { "width": 1200, "height": 450, "unit": "px", "exportDpi": 96, "outputIntent": "screen" }, "background": { "type": "solid", "color": "#4f46e5" }, "elements": [ { "id": "headline", "type": "text", "x": 64, "y": 160, "width": 1072, "height": 130, "rotation": 0, "opacity": 1, "fill": { "type": "solid", "color": "#ffffff" }, "stroke": "transparent", "strokeWidth": 0, "text": "20% OFF", "fontFamily": "Montserrat", "fontSize": 96, "fontStyle": "normal", "fontWeight": 800, "textDecoration": "none", "align": "center", "verticalAlign": "middle", "wrap": "none", "ellipsis": false, "lineHeight": 1.1, "letterSpacing": 0, "isVariable": true, "variableName": "offer", "defaultValue": "20% OFF", "draggable": true, "visible": true, "locked": false } ]}Scopes: validation needs design:read, the preview needs render, template and project writes need templates:write. A default API key carries design:read, templates:read and render; tick templates:write when you create the key. See Authentication.
Optional fields with defaults
Section titled “Optional fields with defaults”A design sent to any of the four endpoints below (validate, render, create, update) may omit an element field that has a declared default; the API fills it in from the schema before validating, so you never have to write out boilerplate values that never change. The fill applies to top-level elements and, recursively, to every group child. From that point on — validation, the rendered image, the render receipt and the stored template — everything sees the complete design; nothing downstream (the designer, the image renderer, GET /api/v1/templates/{id}?include=canvas) ever sees a design with fields missing.
| Element | Defaults |
|---|---|
| every element | rotation: 0, opacity: 1, draggable: true, visible: true, locked: false |
every element except group | stroke: "#000000", strokeWidth: 0 |
line, arrow | lineCap: "round", lineJoin: "round", dash: [] |
arrow | pointerLength: 15, pointerWidth: 15 |
text | fontStyle: "normal", fontWeight: 400, textDecoration: "none", align: "left", verticalAlign: "top", wrap: "word", ellipsis: false, lineHeight: 1.2, letterSpacing: 0 |
group | scaleX: 1, scaleY: 1 |
Fields with no default stay required: id, type, x, y, width, height, fill (not on group), and each type’s own required fields (text, fontFamily, fontSize on text; points on line/arrow). A strokeWidth above 0 without a stroke still draws a black outline, since stroke defaults to #000000. Sending an explicit null for a defaultable field is not filled — it is reported as SCHEMA_VIOLATION at that field’s path, same as any other type mismatch. A value equal to its default ("rotation": 0, say) is valid to send; the MCP server simply omits it when it hands a stored design back to a model.
This is the same table the MCP server’s get_design_reference topic guide (also readable as zandovi://guide) and the design reference’s elements topic show; a field’s constraints there name its default and it drops out of that type’s required list.
Canvas size
Section titled “Canvas size”Take the viewport from a standard preset when the brief names a format (a social post, a business card, an A4 flyer, a ticket). The design reference’s presets topic (GET /api/v1/design-reference/presets, or get_design_reference with topic presets over MCP) lists them by category with the exact width, height, unit, exportDpi and outputIntent to copy. Preset sizes are design-space pixels at 96 DPI: a social post is 1080×1080, a business card 1050×600, an A4 flyer 794×1123 with unit: "cm", exportDpi: 300, outputIntent: "print". Print presets render at 300 DPI on export; never set the viewport to the 300-DPI pixel count (the size the in-app canvas size reference lists for paper presets), which exceeds the preview cap below.
Step 1: validate
Section titled “Step 1: validate”POST /api/v1/designs/validateRuns the JSON schema and the design rules over a design without saving or rendering it. Free: consumes no quota. Always answers 200 for a well-formed request; read valid.
jq -n --slurpfile d design.json '{design: $d[0]}' \ | curl https://app.zandovi.com/api/v1/designs/validate \ -H "X-Api-Key: $ZANDOVI_API_KEY" \ -H "Content-Type: application/json" \ -d @-Request body
| Field | Type | Description |
|---|---|---|
design | object | The canvas design (required) |
viewportWidth, viewportHeight | integer | Optional. The size you intend the design to have; a difference from design.viewport is reported as VIEWPORT_MISMATCH |
Response — 200 OK:
{ "valid": false, "capabilitiesVersion": "1.0", "errors": [ { "path": "/elements/0/fontFamily", "code": "FONT_NOT_AVAILABLE", "message": "Font \"Montserat\" is not available.", "hint": "Closest available: Montserrat, Manrope, Merriweather.", "severity": "ERROR" } ], "warnings": [], "derivedSchema": { "variables": [ { "name": "offer", "type": "text", "required": false, "defaultValue": "20% OFF" } ] }, "stats": { "elements": 1, "visibleElements": 1, "variables": 1, "estimatedOutputPixels": 540000 }}Each violation carries a JSON Pointer path into the design you sent, a code from the table below, a message, an optional hint and a severity. An error makes the design invalid and blocks the render and the write; a warning does not block but usually points at a real problem. Errors and warnings are each capped at 50, ordered by element then rule; truncated: true appears when a cap was hit. derivedSchema.variables is what GET /api/v1/templates/{id} would list once the design is saved. Iterate here until valid is true.
Step 2: render a preview
Section titled “Step 2: render a preview”POST /api/v1/designs/renderValidates the design again, renders it and returns the image together with a render receipt. Charges one API render, only after the design has passed validation, the templateId checks when you send one, and the size caps: an invalid or oversize design costs nothing.
jq -n --slurpfile d design.json '{design: $d[0], format: "png", variables: {offer: "30% OFF"}}' \ | curl https://app.zandovi.com/api/v1/designs/render \ -H "X-Api-Key: $ZANDOVI_API_KEY" \ -H "Content-Type: application/json" \ -d @- \ | tee render.json \ | jq -r '.image.content' | base64 -d > preview.pngRequest body
| Field | Type | Description |
|---|---|---|
design | object | The canvas design (required) |
format | string | png (default), jpeg or webp. Previews are raster only; PDF is not available here |
scale | integer | Integer multiplier, 1 (default) or 2 |
variables | object | Optional sample values by variable name, substituted exactly as the generate endpoint substitutes them. They are not part of the receipt |
templateId | UUID | Optional. The template this preview’s receipt will update. The PATCH’s own checks then run before the charge: a template you can’t see (404 TEMPLATE_NOT_FOUND), one another member created (403 TEMPLATE_FORBIDDEN), or a design that drops or retypes one of its variables (422 SCHEMA_BREAKING_CHANGE) costs nothing. Not part of the receipt |
Response — 200 OK, with the X-Quota-* headers:
{ "image": { "content": "<base64>", "mimeType": "image/png", "width": 1200, "height": 450 }, "renderReceipt": "v1.eyJ...", "warnings": [], "capabilitiesVersion": "1.0"}The preview cap
Section titled “The preview cap”Previews are for seeing, template renders are for output. A preview is refused with 422 PREVIEW_SIZE_LIMIT_EXCEEDED (nothing charged) when viewport.width × viewport.height × scale² exceeds min(your plan’s pixel cap, 4,000,000) or when scale is above 2; details carries outputPixels, maxOutputPixels and maxScale. Every standard preset fits at scale 1. For full-resolution output, save the template and use POST /api/v1/templates/{templateId}/generate.
If you pass variables and an image you supplied for an image variable cannot be loaded, the preview fails with 422 IMAGE_VARIABLE_UNREACHABLE: details.variable names the variable and details.reason says why (not_found, forbidden, timeout, not_an_image, blocked_url or too_large). No image is produced and the unit is refunded. A null variable value counts as no value, so the design’s default applies.
The render receipt
Section titled “The render receipt”renderReceipt is an opaque, signed string that proves this exact design was rendered by your organization. Present it as renderReceipt on the create or update call below.
- It is bound to the design in canonical form: key order and whitespace do not matter, but change any value or reorder two elements and the receipt no longer matches (
RENDER_PROOF_MISMATCH). Element order matters because it is the z-order. - It is valid for 60 minutes from the render (
RENDER_PROOF_EXPIREDafterwards). Render again to get a fresh one. - It is reusable: nothing is burned on verification, so one preview can back a create and any number of later updates of the same design within the hour.
- Do not store it beyond the loop that produced it, and do not log it. It is a proof of work, not a token: it grants nothing on its own and is useless after an hour.
- A receipt from another organization, a tampered one or a made-up string is
RENDER_PROOF_INVALIDorRENDER_PROOF_MISMATCH; a missing one isRENDER_PROOF_REQUIRED.
Step 3: create the template
Section titled “Step 3: create the template”POST /api/v1/templatesCreates a new private template in a project owned by the calling account (the API cannot create shared templates; share it from the app). The design is validated once more, the receipt is checked, then the row is written and the preview thumbnail is copied onto it.
jq -n --slurpfile d design.json --slurpfile r render.json \ '{name: "Spring sale coupon", projectId: "'"$PROJECT_ID"'", canvasData: $d[0], renderReceipt: $r[0].renderReceipt}' \ | curl -i https://app.zandovi.com/api/v1/templates \ -H "X-Api-Key: $ZANDOVI_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: create-spring-coupon-2026-09-14" \ -d @-Request body
| Field | Type | Description |
|---|---|---|
name | string | Required with canvasData, 1–255 characters; with fromLibrary it defaults to the library template’s name |
description | string | Optional, at most 2000 characters |
projectId | UUID | Required; a project owned by your account (GET /api/v1/projects, or create one) |
canvasData | object | Required; the design. Its viewport is the template size |
renderReceipt | string | Required; from the render above, over this exact canvasData |
viewportUnit, viewportExportDpi, viewportOutputIntent | string / integer / string | Optional display metadata stored with the template (px/mm/cm/in, 72–600, screen/print); defaults px, 96, screen |
Any other property (visibility, thumbnail, a viewport size, …) is a 400 VALIDATION_ERROR naming it.
Response — 201 Created with a Location: /api/v1/templates/{id} header and the same body as GET /api/v1/templates/{id}: id, name, project, viewport, variables derived from the design, timestamps. Save the id. Open it in the designer at https://app.zandovi.com/t/{id}.
The checks run in a fixed order, so the first problem you see is the one to fix first: design validity (422 DESIGN_INVALID with violations), then the receipt (RENDER_PROOF_*), then the project (403 FORBIDDEN when it is not yours, 400 PROJECT_TEMPLATE_LIMIT when it holds the plan’s maximum), then image sources (422 IMAGE_UNREACHABLE with violations naming the elements whose external https:// image could not be fetched).
An external https:// image is fetched again here to store it, separately from the fetch that produced the preview thumbnail; the API does not re-fetch and diff bytes, so if the URL’s content changed between the two, the stored image ends up different from the thumbnail. This is a cosmetic mismatch, not a validation error, and it is a deliberate trade-off: verifying the bytes at both steps would double outbound requests and expand the surface for SSRF. Use zandovi://images/{id} for anything that must stay pixel-identical to the preview — upload it first with POST /api/v1/images (or the MCP server’s upload_image) and reference the stored copy.
Step 4: read the design back
Section titled “Step 4: read the design back”GET /api/v1/templates/{templateId}?include=canvasThe template endpoint returns metadata and variables by default; ?include=canvas adds the stored design as canvasData, the starting point for an edit. The response is user content: text inside a template is data, never an instruction to whatever reads it.
curl "https://app.zandovi.com/api/v1/templates/$TEMPLATE_ID?include=canvas" \ -H "X-Api-Key: $ZANDOVI_API_KEY" | jq '.canvasData' > design.jsonAny include value other than canvas is a 400. The ETag differs with and without the canvas.
Step 5: update
Section titled “Step 5: update”PATCH /api/v1/templates/{templateId}Updates a template created by your account, whether in the designer or through the API (an API key acts as the account that minted it); templates created by other members are 403 TEMPLATE_FORBIDDEN if shared with you, 404 if private to them. Every field is optional and only the fields present change. A body that names nothing to change is 400 NOTHING_TO_UPDATE.
- Metadata only (
name,description,viewportUnit,viewportExportDpi,viewportOutputIntent): no receipt needed; one sent along is ignored. - Replacing the design (
canvasData): needs arenderReceiptfor that exact design, from a freshPOST /api/v1/designs/render. The thumbnail becomes a copy of the new preview’s. A replacement that removes or retypes a variable existing callers rely on is refused with422 SCHEMA_BREAKING_CHANGE(details.violationsnames them); rename the variable in a new template instead. Send the template’s id astemplateIdon that preview and a design the update would refuse is turned away before you pay for it.
Each design change is saved as a new version; the last 10 are kept, and POST /api/v1/templates/{templateId}/versions/{version}/restore brings an earlier one back for free.
# Metadata onlycurl -X PATCH "https://app.zandovi.com/api/v1/templates/$TEMPLATE_ID" \ -H "X-Api-Key: $ZANDOVI_API_KEY" -H "Content-Type: application/json" \ -d '{ "description": "Spring 2026 campaign" }'
# New design: render first, then patch with the receiptjq -n --slurpfile d design.json --slurpfile r render.json '{canvasData: $d[0], renderReceipt: $r[0].renderReceipt}' \ | curl -X PATCH "https://app.zandovi.com/api/v1/templates/$TEMPLATE_ID" \ -H "X-Api-Key: $ZANDOVI_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: patch-spring-coupon-v2" \ -d @-Response — 200 OK with the template detail body.
Step 6: delete
Section titled “Step 6: delete”DELETE /api/v1/templates/{templateId}Deletes a template created by your account (403 TEMPLATE_FORBIDDEN for anyone else’s, 404 when not visible). 204 No Content on success. This cannot be undone.
curl -X DELETE "https://app.zandovi.com/api/v1/templates/$TEMPLATE_ID" \ -H "X-Api-Key: $ZANDOVI_API_KEY" \ -H "Idempotency-Key: delete-spring-coupon"Start from a library template
Section titled “Start from a library template”Zandovi ships a library of 190 built-in designs — the same ones in the in-app template gallery — that you can instantiate into your own project without writing a design at all. There is no REST endpoint to browse the library; use the Zandovi MCP server’s list_templates(source: "library") and get_template to find an id, by category or a search term. The create call is the same POST /api/v1/templates above, with fromLibrary in place of canvasData and renderReceipt:
curl -i https://app.zandovi.com/api/v1/templates \ -H "X-Api-Key: $ZANDOVI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fromLibrary": "coupon-flash-sale", "projectId": "'"$PROJECT_ID"'" }'This skips validation and rendering entirely — the library design is already known-good, so nothing is charged; the library preview becomes the thumbnail, and name and description default to the library template’s. An id matching no library template is 404 LIBRARY_TEMPLATE_NOT_FOUND (“No library template with that id.”); if the library cannot be reached, the answer is 503 LIBRARY_UNAVAILABLE and nothing is created — retry later. Sending both fromLibrary and canvasData is 400 AMBIGUOUS_CREATE. From there it is an ordinary template: PATCH, edit_template or DELETE it like any other.
Partial edits with the MCP server
Section titled “Partial edits with the MCP server”The API itself has no endpoint for a partial update: PATCH /api/v1/templates/{id} always replaces the whole canvasData, as in Step 5. If you author through the Zandovi MCP server rather than calling the API directly, its edit_template(templateId, ops[]) tool changes one or a few elements — setElement, addElement, removeElement, moveElement, setBackground, setViewport, plus metadata ops (setName, setDescription, free when that is all the call does) and replaceDesign (a whole-design swap, only valid as the first op) — without you having to fetch, edit and resend the entire design; it fetches the current canvas itself, applies the ops in order, then renders and calls this same PATCH endpoint with the result. A target that matches no element (or more than one), or a misplaced replaceDesign, aborts before anything renders or is charged.
Projects
Section titled “Projects”POST /api/v1/projectsGET /api/v1/projects/{projectId}POST creates a new private project owned by your account, to hold templates: body { "name": "…", "description": "…" } (name required, 1–255 characters), 201 Created with a Location header and the project item. At the plan’s project cap the answer is 400 PROJECT_LIMIT. GET /api/v1/projects/{projectId} returns one project you can see. Both need templates:write and templates:read respectively; see Templates & Schema Discovery for the list endpoints.
Idempotency
Section titled “Idempotency”POST /api/v1/templates, PATCH /api/v1/templates/{id}, DELETE /api/v1/templates/{id}, POST /api/v1/templates/{id}/versions/{version}/restore, POST /api/v1/projects and the image upload and delete honour an optional Idempotency-Key request header (1–255 visible ASCII characters; anything else is 400 INVALID_IDEMPOTENCY_KEY). Always send one from automation, so a retry after a timeout cannot create a second template.
- The key is scoped to your organization, the credential, the method and the path, and is kept for 24 hours.
- A repeat with the same key and body replays the stored answer: same status and JSON body, with the header
Idempotency-Replayed: true. The replay carries only the stored status, content type and body; aLocationheader from the original201is not replayed, so read the id from the body. - The body comparison ignores
renderReceipt: a retry that rendered again and carries a fresh receipt for the same design is still the same request. - The same key with a different body is
422 IDEMPOTENCY_KEY_REUSED; use a new key for a new request. - A concurrent twin (the first request is still running) is
409 IDEMPOTENCY_IN_PROGRESS; retry shortly to receive its result. - Only final answers are stored:
2xxand4xx, except the422 RENDER_PROOF_*refusals, so a retry with a fresh receipt under the same key runs. A5xxis never stored and a retry re-executes. - The header is ignored on renders (
POST /api/v1/designs/renderis honestly a second render) and on reads.
The render itself has no server-side deduplication, so a preview retried after a client timeout consumes a second render; the receipt from either is fine.
Validation codes
Section titled “Validation codes”The catalogue below is generated from design-rules.json, the same file the validator and the MCP knowledge pack are built from. path in a violation points at the element or field; hint says how to fix it where the message does not.
| Code | Severity | Meaning |
|---|---|---|
INLINE_IMAGE_DATA | error | An image src is a data: URL; upload the image and reference it as zandovi://images/{id} or a public https:// URL. |
SCHEMA_VIOLATION | error | The design breaks the JSON schema at the given path (wrong type, unknown field, out-of-range value). |
SCHEMA_VERSION_UNSUPPORTED | error | schemaVersion is not “1.0”, the only version this deployment accepts. |
ELEMENT_COUNT_EXCEEDED | error | The design has more than 500 elements. |
VIEWPORT_MISMATCH | error | The request’s viewportWidth/viewportHeight differ from design.viewport. |
BLANK_CANVAS | error | No visible element has non-transparent paint and the background is a plain colour, so nothing would render. |
ELEMENT_ZERO_SIZE | error | A visible element has width or height of 0 or less. |
ELEMENT_OFF_CANVAS | error | A visible element’s bounding box (centre-origin aware) lies entirely outside the viewport. |
VARIABLE_ELEMENT_OCCLUDED | error | A variable-bearing element is fully covered by a later opaque, unrotated rectangle or image, so its value would never show. |
FONT_NOT_AVAILABLE | error | fontFamily is not one of the bundled fonts; the hint lists the closest available names. |
GLYPH_FALLBACK | warning | A character in text/defaultValue is not covered by fontFamily but is covered by the fallback chain, which draws it instead. |
GLYPH_MISSING | warning | A character in text/defaultValue is covered by neither fontFamily nor the fallback chain and renders as a missing-glyph box. |
ICON_NOT_FOUND | error | iconName is not a bundled Lucide icon and the element carries no usable iconGlyph. |
ICON_GLYPH_INVALID | error | iconGlyph does not match the allowed Lucide-shaped geometry (tags, attributes, ≤32 nodes, ≤2 KB). |
COLOR_INVALID | error | A fill, stroke, color, shadowColor or gradient stop is not a parseable CSS colour (#rgb, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl(), hsla(), transparent or a named colour). |
VARIABLE_NAME_INVALID | error | variableName must match ^[A-Za-z_][A-Za-z0-9_]{0,63}$. |
VARIABLE_FLAG_MISSING | error | variableName is set without isVariable: true (text, qrcode, barcode) or isPlaceholder: true (image), so the binding is inert. |
VARIABLE_MISSING_DEFAULT | error | A variable element has no defaultValue, or it is blank; the default is what renders when no value is supplied. |
VARIABLE_CONFLICT | error | The same variableName is used on two elements of different type or with different validation rules. |
VARIABLE_DEFAULT_MISMATCH | warning | Elements of the same type share a variableName but have different defaults (defaultValue, data for a QR/barcode without one, defaultSrc); without a value each renders its own. |
QR_DATA_EMPTY | error | A non-variable qrcode has blank data. |
BARCODE_PAYLOAD_INVALID | error | A barcode’s payload (data, or defaultValue for a variable barcode) does not fit its format (EAN13 13 digits, UPC 12, ITF14 14, CODE39 charset). |
IMAGE_SRC_MISSING | error | An image has a blank src and is not a placeholder (isPlaceholder: true). |
IMAGE_NOT_OWNED | error | A zandovi://images/{id} reference is not visible to this organization and account (missing, another org’s, or private to another member). |
FONT_WEIGHT_NOT_AVAILABLE | warning | fontWeight is not offered by the font family; the renderer substitutes the nearest weight. |
QR_LOGO_NOT_RENDERED | warning | logoSrc, logoSize or logoPadding is set on a qrcode, but the renderer does not draw QR logos. |
TEXT_EMPTY | warning | A visible text element has blank text and no variable binding. |
AUTOFIT_NO_MAX | warning | autoFit is true without a maxFontSize, so fontSize is the ceiling and the text can only shrink. |
GROUP_EMPTY | warning | A group has no children. |
ELEMENT_OCCLUDED | warning | A non-variable visible element is fully covered by a later opaque, unrotated rectangle or image. |
GRADIENT_POINT_OUT_OF_RANGE | warning | A gradient’s startPoint, endPoint, centerPoint or radius lies outside 0..1; the renderer reads them as fractions of the painted box, so pixel values stretch the gradient far past the element and it paints as a flat colour. |
The write endpoints add their own codes: DESIGN_INVALID (422, wraps the errors above as violations), PREVIEW_SIZE_LIMIT_EXCEEDED (422), RENDER_PROOF_REQUIRED / RENDER_PROOF_MISMATCH / RENDER_PROOF_EXPIRED / RENDER_PROOF_INVALID (422), IMAGE_UNREACHABLE (422), IMAGE_VARIABLE_UNREACHABLE (422, on the preview; the unit is refunded), SCHEMA_BREAKING_CHANGE (422), VERSION_NOT_RESTORABLE (422, on a restore), NOTHING_TO_UPDATE (400), TEMPLATE_FORBIDDEN (403), PROJECT_TEMPLATE_LIMIT and PROJECT_LIMIT (400), RENDER_FAILED (502, the unit is refunded) and the idempotency codes above. See Errors, quotas & rate limits.