Skip to content

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.

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.

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.

ElementDefaults
every elementrotation: 0, opacity: 1, draggable: true, visible: true, locked: false
every element except groupstroke: "#000000", strokeWidth: 0
line, arrowlineCap: "round", lineJoin: "round", dash: []
arrowpointerLength: 15, pointerWidth: 15
textfontStyle: "normal", fontWeight: 400, textDecoration: "none", align: "left", verticalAlign: "top", wrap: "word", ellipsis: false, lineHeight: 1.2, letterSpacing: 0
groupscaleX: 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.

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.

POST /api/v1/designs/validate

Runs 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.

Terminal window
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

FieldTypeDescription
designobjectThe canvas design (required)
viewportWidth, viewportHeightintegerOptional. 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.

POST /api/v1/designs/render

Validates 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.

Terminal window
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.png

Request body

FieldTypeDescription
designobjectThe canvas design (required)
formatstringpng (default), jpeg or webp. Previews are raster only; PDF is not available here
scaleintegerInteger multiplier, 1 (default) or 2
variablesobjectOptional sample values by variable name, substituted exactly as the generate endpoint substitutes them. They are not part of the receipt
templateIdUUIDOptional. 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"
}

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.

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_EXPIRED afterwards). 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_INVALID or RENDER_PROOF_MISMATCH; a missing one is RENDER_PROOF_REQUIRED.
POST /api/v1/templates

Creates 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.

Terminal window
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

FieldTypeDescription
namestringRequired with canvasData, 1–255 characters; with fromLibrary it defaults to the library template’s name
descriptionstringOptional, at most 2000 characters
projectIdUUIDRequired; a project owned by your account (GET /api/v1/projects, or create one)
canvasDataobjectRequired; the design. Its viewport is the template size
renderReceiptstringRequired; from the render above, over this exact canvasData
viewportUnit, viewportExportDpi, viewportOutputIntentstring / integer / stringOptional 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.

GET /api/v1/templates/{templateId}?include=canvas

The 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.

Terminal window
curl "https://app.zandovi.com/api/v1/templates/$TEMPLATE_ID?include=canvas" \
-H "X-Api-Key: $ZANDOVI_API_KEY" | jq '.canvasData' > design.json

Any include value other than canvas is a 400. The ETag differs with and without the canvas.

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 a renderReceipt for that exact design, from a fresh POST /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 with 422 SCHEMA_BREAKING_CHANGE (details.violations names them); rename the variable in a new template instead. Send the template’s id as templateId on 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.

Terminal window
# Metadata only
curl -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 receipt
jq -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.

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.

Terminal window
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"

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:

Terminal window
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.

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.

POST /api/v1/projects
GET /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.

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; a Location header from the original 201 is 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: 2xx and 4xx, except the 422 RENDER_PROOF_* refusals, so a retry with a fresh receipt under the same key runs. A 5xx is never stored and a retry re-executes.
  • The header is ignored on renders (POST /api/v1/designs/render is 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.

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.

CodeSeverityMeaning
INLINE_IMAGE_DATAerrorAn image src is a data: URL; upload the image and reference it as zandovi://images/{id} or a public https:// URL.
SCHEMA_VIOLATIONerrorThe design breaks the JSON schema at the given path (wrong type, unknown field, out-of-range value).
SCHEMA_VERSION_UNSUPPORTEDerrorschemaVersion is not “1.0”, the only version this deployment accepts.
ELEMENT_COUNT_EXCEEDEDerrorThe design has more than 500 elements.
VIEWPORT_MISMATCHerrorThe request’s viewportWidth/viewportHeight differ from design.viewport.
BLANK_CANVASerrorNo visible element has non-transparent paint and the background is a plain colour, so nothing would render.
ELEMENT_ZERO_SIZEerrorA visible element has width or height of 0 or less.
ELEMENT_OFF_CANVASerrorA visible element’s bounding box (centre-origin aware) lies entirely outside the viewport.
VARIABLE_ELEMENT_OCCLUDEDerrorA variable-bearing element is fully covered by a later opaque, unrotated rectangle or image, so its value would never show.
FONT_NOT_AVAILABLEerrorfontFamily is not one of the bundled fonts; the hint lists the closest available names.
GLYPH_FALLBACKwarningA character in text/defaultValue is not covered by fontFamily but is covered by the fallback chain, which draws it instead.
GLYPH_MISSINGwarningA character in text/defaultValue is covered by neither fontFamily nor the fallback chain and renders as a missing-glyph box.
ICON_NOT_FOUNDerroriconName is not a bundled Lucide icon and the element carries no usable iconGlyph.
ICON_GLYPH_INVALIDerroriconGlyph does not match the allowed Lucide-shaped geometry (tags, attributes, ≤32 nodes, ≤2 KB).
COLOR_INVALIDerrorA 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_INVALIDerrorvariableName must match ^[A-Za-z_][A-Za-z0-9_]{0,63}$.
VARIABLE_FLAG_MISSINGerrorvariableName is set without isVariable: true (text, qrcode, barcode) or isPlaceholder: true (image), so the binding is inert.
VARIABLE_MISSING_DEFAULTerrorA variable element has no defaultValue, or it is blank; the default is what renders when no value is supplied.
VARIABLE_CONFLICTerrorThe same variableName is used on two elements of different type or with different validation rules.
VARIABLE_DEFAULT_MISMATCHwarningElements 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_EMPTYerrorA non-variable qrcode has blank data.
BARCODE_PAYLOAD_INVALIDerrorA 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_MISSINGerrorAn image has a blank src and is not a placeholder (isPlaceholder: true).
IMAGE_NOT_OWNEDerrorA zandovi://images/{id} reference is not visible to this organization and account (missing, another org’s, or private to another member).
FONT_WEIGHT_NOT_AVAILABLEwarningfontWeight is not offered by the font family; the renderer substitutes the nearest weight.
QR_LOGO_NOT_RENDEREDwarninglogoSrc, logoSize or logoPadding is set on a qrcode, but the renderer does not draw QR logos.
TEXT_EMPTYwarningA visible text element has blank text and no variable binding.
AUTOFIT_NO_MAXwarningautoFit is true without a maxFontSize, so fontSize is the ceiling and the text can only shrink.
GROUP_EMPTYwarningA group has no children.
ELEMENT_OCCLUDEDwarningA non-variable visible element is fully covered by a later opaque, unrotated rectangle or image.
GRADIENT_POINT_OUT_OF_RANGEwarningA 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.