Start with something useful.
Fetch a starter, fill its variables, and send it to the renderer. Endpoints live on your installation's origin. Public CORS supports cross-origin requests without CMS cookies or an API key. Batch status, download, and deletion require the job's secret bearer token.
JavaScript · download a PNG
// Run on your Zero Render site, or replace location.origin with its URL.
const base = location.origin;
const catalogue = await fetch(base + '/api/v1/templates');
if (!catalogue.ok) throw new Error('Could not load templates');
const { templates } = await catalogue.json();
const invoice = templates.find((template) => template.id === 'invoice');
if (!invoice) throw new Error('Invoice template is unavailable');
const response = await fetch(base + '/api/v1/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
design: invoice.design,
variables: invoice.variables,
format: 'png'
})
});
if (!response.ok) {
const retryAfter = response.headers.get('Retry-After');
throw new Error(response.status === 429
? 'Rate limited. Retry after ' + retryAfter + ' seconds.'
: 'Render failed: HTTP ' + response.status);
}
const url = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = url;
link.download = 'invoice.png';
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);cURL · download a PDF
This example uses curl and jq. Change the base URL to your installation. A successful single render returns a binary file, not JSON.
# Set BASE to the origin where you installed Zero Render.
BASE="https://templatedesign.zhst.app"
# jq builds a render request from the actual invoice template.
curl --fail-with-body "$BASE/api/v1/templates" \
| jq '{design: (.templates[] | select(.id == "invoice") | .design),
variables: (.templates[] | select(.id == "invoice") | .variables),
format: "pdf"}' > render-request.json
curl --fail-with-body --request POST "$BASE/api/v1/render" \
--header 'Content-Type: application/json' \
--data-binary @render-request.json \
--output invoice.pdfUse the designer, then choose Save JSON. Add format to the resulting {design, variables} object to render it.
Saved templates. Exact versions.
/api/v1/templatesReturns {templates: [...]}. All entries contain id, version, title, category, and description. Built-ins invoice, social, certificate, and blank are version 1 and also include their design and default variables. Saved entries contain metadata only, for each ID's latest published version.
/api/v1/templates/your-id?version=1Returns {template: {id, version, design, variables, ...}}. An exact version is required. Unknown, private, and revoked versions return 404; private metadata and saved defaults are not listed publicly.
Manage the site-wide library through EmDash administration. Import designer JSON, save a private immutable version, then explicitly confirm publication of its design and defaults. Later saves never overwrite or automatically publish earlier versions. Each published version remains available until individually unpublished. Management requires an administrator session, not an anonymous API key.
The invoice is a sample layout, not an accounting engine. Amounts and totals are text; replace sample values before use.
Readiness
/api/v1/healthChecks the quota store, not rendering, queue delivery, or cleanup. Operators must monitor background workers separately.
One request. Three formats.
/api/v1/renderSend JSON with either a complete design or both templateId and version. Do not combine these branches. variables is optional; overrides merge with saved defaults for an ID-based request. format is required: png, pdf, or svg.
Render a published version
{
"templateId": "invoice",
"version": 1,
"variables": {
"client": "Example customer"
},
"format": "pdf"
}Render an inline design
{
"design": {
"version": 1,
"name": "A personal hello",
"width": 800,
"height": 450,
"background": "#F7F6F2",
"elements": [
{
"id": "accent",
"type": "rect",
"x": 48,
"y": 48,
"width": 80,
"height": 8,
"fill": "#315BBA",
"radius": 4
},
{
"id": "greeting",
"type": "text",
"x": 48,
"y": 128,
"width": 704,
"height": 160,
"text": "Hello, {{name}}.",
"fontSize": 48,
"color": "#28312F",
"fontWeight": 400,
"align": "left"
}
]
},
"variables": {
"name": "Alex"
},
"format": "png"
}Success returns a download with Content-Disposition and image/png, application/pdf, or image/svg+xml. OPTIONS supports CORS preflight. A single request uses one shared render credit.
CSV to a private ZIP.
The batch interface reads UTF-8 CSV, maps columns to variables, and previews a selected row before submission. Quoted commas and newlines are supported. CSV values remain plain text, not formulas. The API takes resolved row objects, not raw CSV.
/api/v1/batchesAdd rows to either render request shape. Each row is a variable object overriding the request or template defaults. Every row and the resolved snapshot are validated before admission. 202 returns {id, token, status: "queued", expiresAt}; expiresAt is Unix milliseconds. Save the token securely: it is returned only at creation, with no recovery account.
const base = location.origin;
const response = await fetch(base + '/api/v1/batches', {
method: 'POST', credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateId: 'invoice', version: 1, format: 'pdf',
rows: [{ client: 'Ada' }, { client: 'Grace' }]
})
});
if (response.status !== 202) throw new Error(await response.text());
const job = await response.json(); // Keep token private; never put it in a URL.
const options = {
credentials: 'omit',
headers: { Authorization: 'Bearer ' + job.token }
};
let status;
while (Date.now() < job.expiresAt) {
const check = await fetch(base + '/api/v1/batches/' + job.id, options);
if (!check.ok) throw new Error(await check.text());
status = await check.json();
if (status.status === 'failed') throw new Error(status.error.message);
if (status.status === 'completed') break;
await new Promise(resolve => setTimeout(resolve, 1500));
}
if (!status?.downloadReady) throw new Error('Job expired before completion');
const archive = await fetch(base + '/api/v1/batches/' + job.id + '/download', options);
if (!archive.ok) throw new Error(await archive.text());
const url = URL.createObjectURL(await archive.blob());
const link = document.createElement('a');
link.href = url; link.download = 'batch.zip';
document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
// Optional early revocation:
// await fetch(base + '/api/v1/batches/' + job.id, { ...options, method: 'DELETE' });Retrieve or revoke a job
GET /api/v1/batches/IDreturnsstatus(queued, running, completed, failed),completed,total,expiresAt,downloadReady, and an error on failure.GET /api/v1/batches/ID/downloadreturnsapplication/zipcontainingrow-001.ext,row-002.ext, and so on. Before completion it returns409.DELETE /api/v1/batches/IDreturns204, immediately revokes access, and starts cleanup.
All three require Authorization: Bearer TOKEN. Missing/wrong tokens and expired jobs return 404. Tokens never belong in URLs. Public CORS allows the Authorization header without CMS cookies.
Bounds and retention
1–20 rows, 20 million combined canvas pixels, 1 MiB request and resolved snapshot, and a 32 MiB ZIP including fonts and filenames. The interface accepts CSV files up to 256 KiB and 50 columns. Site capacity is 10 active jobs, two per client IP, and 100 retained unexpired jobs. Capacity returns 503 BATCH_CAPACITY with Retry-After.
Each row costs one of the same 20 render credits/minute used by single exports. Invalid attempts cost at least one credit; failed validation or admission may consume a full reservation. Queued work runs independently of polling. Accepted jobs use a snapshot: unpublishing a template does not cancel them.
Jobs expire one hour after acceptance. Inputs are removed on completion/final failure; ZIP and authorization records expire after the hour. Access stops at expiry even if storage cleanup is delayed. Cleanup requires a running background worker and healthy storage. The browser keeps the token only in this tab's session storage; closing the session loses access. Downloaded copies and operator backups have separate lifecycles.
Plain JSON. Precisely placed.
A design has version: 1, a name, pixel width and height, a hex background, and an elements array. Layers paint in array order, from back to front. Every layer has a unique id, a type, and pixel x, y, width, and height. Its entire box must fit inside the canvas.
| Layer type | Additional properties |
|---|---|
text | text, fontSize, color, fontWeight (400 or 700), align (left, center, or right) |
rect | fill, radius |
ellipse | fill |
image | src: an embedded PNG or JPEG base64 data URL |
Colors use six-digit hex values such as #315BBA. Text is plain text, not HTML. Images cannot be remote URLs, SVG, or animated PNG. Rotation and arbitrary SVG markup are not supported.
Variables
Use a placeholder such as {{customer_name}} in a text layer and pass {"customer_name":"Alex"} in variables. Values must be strings. Missing values leave the original placeholder visible. Names start with a letter and contain only letters, digits, and underscores, up to 40 characters. Variables substitute text; they do not evaluate expressions, run scripts, or calculate invoice totals.
Predictable limits. Useful errors.
| Boundary | Limit |
|---|---|
| Shared render credits | 20 per minute per IP; one per single export or batch row |
| Request body | 1 MiB (1,048,576 UTF-8 bytes), including JSON, variables, and images |
| Canvas dimensions | 64–2,000 px per side; at most 4 million pixels |
| Layers | 100; unique alphanumeric/hyphen IDs, up to 64 characters |
| Text | 4,000 characters per layer; 20,000 total before substitution |
| Expanded text | 20,000 characters per layer; 100,000 total after substitution |
| Font size | 8–200 px |
| Variables | 50 entries; 2,000 characters per value |
| Images | PNG/JPEG, up to 2,000 × 2,000 px; 4 million decoded source pixels combined; 700,000 data-URL characters per image, 900,000 combined |
| Editor image upload | 400 KiB per file, subject to project and API limits |
Invalid requests are rejected rather than silently corrected. Keep an editable JSON copy; fix invalid input before retrying.
Error handling
- 400 / 413: correct the JSON/design/row values or reduce the payload. Preserve the user's input.
- 404: the published version or authorized job is unavailable.
- 409: a batch ZIP is not ready, or an administrator saved against a stale version.
- 429: wait for
Retry-After.RateLimit-Resetis remaining seconds, not an epoch timestamp. Do not retry in a tight loop or switch identities. - 503: capacity or a backing service is unavailable; preserve state and offer a manual retry.
Errors use {error: {code, message}}. Rendering fails closed if the quota store is unavailable. Anonymous access does not mean unlimited infrastructure capacity.
Know what leaves the canvas.
PNG
A raster image at the exact design dimensions. A design measuring 800 × 450 px produces an 800 × 450 px image.
A single-page PDF containing the rendered PNG. It is raster, not vector; text is not selectable or independently editable. Pixel measurements are converted to PDF points using points = pixels × 72 / 96. An 800 × 450 px design therefore produces a 600 × 337.5 pt page (8⅓ × 4.6875 inches). There are no print bleed or crop marks.
SVG
Scalable shapes and text, with uploaded raster images embedded. The download embeds the bundled fonts so it does not require a remote font service. SVG viewers vary in font support; use PNG or PDF when consistent raster output matters most.
Fonts and text layout
PNG and PDF use locally bundled Noto Sans Regular and Bold. The bundled fonts support Latin, Greek, and Cyrillic, not every writing system or emoji. The browser preview loads the same local fonts, with a system fallback if they are unavailable. No remote fonts or external image assets are fetched by the renderer.
Text wraps using approximate character measurements and clips to its layer's box. Enlarge that box to reveal more content. This is a bounded document renderer, not a browser layout engine; complex scripts and browser-specific typography are not guaranteed to match.
Your data
The editor saves the project in your browser's local storage. PNG, PDF, and SVG exports send the current design and variables to this installation's server. JSON downloads happen locally. Browser storage can be cleared or unavailable, so keep a JSON backup of work you want to keep.