Batch Rendering
The batch endpoint renders one template against many rows of data in a single job. You submit a rows array; each row becomes one image; the finished job hands you a ZIP archive of all of them. No client-side loop, no hand-rolled retry queue, no reassembling files yourself.
POST /api/v1/batch-jobsBatch jobs are asynchronous: submission returns 202 Accepted immediately with a job ID, the render happens in the background, and you either poll for status or ask to be called back when it finishes.
Lifecycle at a glance
Section titled “Lifecycle at a glance”- Submit —
POST /api/v1/batch-jobswith a template reference and your rows. Returns202with ajobId. - Poll —
GET /api/v1/batch-jobs/{jobId}untilstatusis terminal. Or supply acallbackUrlat submission and be told instead. - Download —
GET /api/v1/batch-jobs/{jobId}/resultfor a download URL or the ZIP itself. - Reconcile (optional) —
GET /api/v1/batch-jobs/{jobId}/itemsfor what each row produced.
| Status | Meaning |
|---|---|
PENDING | Accepted, waiting for a renderer |
PROCESSING | Rendering is underway |
COMPLETED | Every row processed — results are ready |
FAILED | The job failed |
CANCELLED | You cancelled it |
PENDING and PROCESSING are active; the other three are terminal.
Submit a batch job
Section titled “Submit a batch job”POST /api/v1/batch-jobsHeaders
| Header | Required | Description |
|---|---|---|
X-Api-Key | yes | Your API key |
Content-Type | yes | application/json |
Idempotency-Key | no | Client-chosen string (max 255 chars) that makes retries safe — see Idempotency |
Body
| Field | Type | Required | Description |
|---|---|---|---|
template.templateId | UUID | yes | The template to render — one template per job |
template.format | string | no | png (default), jpeg, webp, or pdf |
template.options | object | no | Same render options as the generate endpoint: scale, quality, outputDpi, backgroundColor |
template.output.filenamePattern | string | no | Naming pattern for the files in the ZIP — see Filename patterns |
rows | array | yes | Non-empty array of objects; each object maps variable names to string values, exactly like the variables map on a single render. Each row produces one image. |
callbackUrl | string | no | Publicly reachable https URL (max 2048 chars) to POST a completion summary to, so you don’t have to poll — see Completion Callbacks |
format, options, and filenamePattern apply to the whole job — rows differ only in variable values. callbackUrl sits at the top level, beside template and rows: it’s a delivery instruction about the job, not a render parameter.
There is no project field to send: a job belongs to whichever project its template lives in, and that is what the list endpoint reports back.
curl -X POST https://app.zandovi.com/api/v1/batch-jobs \ -H "X-Api-Key: $ZANDOVI_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: campaign-2026-08-vip" \ -d '{ "template": { "templateId": "019463b8-1234-7890-abcd-ef1234567890", "format": "png", "options": { "scale": 2 }, "output": { "filenamePattern": "{discount_code}" } }, "rows": [ { "first_name": "Alice", "discount_code": "ALICE10" }, { "first_name": "Bob", "discount_code": "BOB20" }, { "first_name": "Carol", "discount_code": "CAROL15" } ] }'On success the response is 202 Accepted:
{ "jobId": "019a5f22-4c1e-7d90-b3a8-6f2e91c04d77", "status": "PENDING", "totalItems": 3, "createdAt": "2026-08-11T09:00:00Z"}Validation happens before billing
Section titled “Validation happens before billing”Every row is validated against the template’s variable schema before the job is created. If any row is invalid, the whole submission is rejected with 400 and code: VARIABLE_VALIDATION_FAILED naming the first offending row — no job is created and nothing is charged. Fix the row and resubmit.
What it costs
Section titled “What it costs”A job with N rows consumes N renders from your monthly API quota, charged at submission. If your remaining quota can’t cover the whole job, the submission is rejected with 429 and nothing is charged — a batch is never partially billed at submit time.
Idempotency
Section titled “Idempotency”Batch submissions are expensive to double-pay, so the submit endpoint supports idempotency keys. Send any unique string (an order ID, a campaign slug, a UUID) in the Idempotency-Key header:
- Retrying the same submission with the same key — after a timeout, a dropped connection, or a crashed worker — returns
202with the original job. You are not billed and nothing renders twice. This holds even for concurrent duplicate submissions. - Reusing a key for a different submission (different template, row count, format, options, or filename pattern) returns
409withcode: BATCH_IDEMPOTENCY_KEY_REUSED. One key belongs to one submission — don’t recycle keys across jobs. - Keys are scoped to your organization and matched for as long as the original job exists.
Filename patterns
Section titled “Filename patterns”By default the images in the result ZIP are named sequentially: image-000.png, image-001.png, … Set template.output.filenamePattern to name them from your data instead:
| Placeholder | Value |
|---|---|
{column_name} | The row’s value for that variable, e.g. {discount_code} |
{index} | Row number, zero-padded to 3 digits (000, 001, …) |
{timestamp} | Job timestamp |
{random} | 6 random characters |
Rules worth knowing:
- Don’t include the extension — the job’s
formatextension is appended automatically.{discount_code}yieldsalice10.png;{discount_code}.pngwould yieldalice10.png.png. - Substituted values are lowercased, and any character outside
a–z,0–9,-,_becomes a hyphen. Characters that are unsafe in filenames (path separators, control characters, Windows-reserved names) are stripped or escaped. - Collisions never lose output: if two rows produce the same filename, the later ones are suffixed (
ticket.png,ticket-2.png,ticket-3.png). - A pattern that sanitizes down to nothing falls back to the sequential name.
The archive is flat — images only, one entry per row that rendered. A row that failed has no
entry (and no manifest file lists it); /items is where you find out which
rows those were, and each row’s filename there is the name it produced or would have produced.
Poll job status
Section titled “Poll job status”GET /api/v1/batch-jobs/{jobId}curl https://app.zandovi.com/api/v1/batch-jobs/$JOB_ID \ -H "X-Api-Key: $ZANDOVI_API_KEY"{ "jobId": "019a5f22-4c1e-7d90-b3a8-6f2e91c04d77", "status": "PROCESSING", "totalItems": 200, "completedItems": 140, "failedItems": 0, "processedItems": 140, "createdAt": "2026-08-11T09:00:00Z", "startedAt": "2026-08-11T09:00:02Z", "completedAt": null, "renderTimeMs": null, "downloadUrl": null, "downloadUrlExpiresAt": null, "errors": [], "completionCallback": null}downloadUrlanddownloadUrlExpiresAtare populated only oncestatusisCOMPLETED.errorslists per-row failures as{ "itemIndex": …, "error": … }—itemIndexrefers to your submittedrowsarray. It is capped at the first 5 failures;failedItemscarries the true count andGET /batch-jobs/{jobId}/itemscarries the whole list.processedItemstracks progress while the job runs. On aCOMPLETEDorFAILEDjob it equalscompletedItems + failedItems; on aCANCELLEDor timed-out job it keeps the count of rows that were rendered before the job stopped.completionCallbackisnullunless the submission supplied acallbackUrl. When it did, it reports the delivery:{ deliveryId, callbackUrl, status, attempts, deliveredAt, lastError }withstatusone ofPENDING,DELIVERED, orFAILED. See Completion Callbacks.- Polling is cheap: status reads sit in the high-rate read tier (see Limits), so polling every second or two is fine.
List a job’s rows
Section titled “List a job’s rows”GET /api/v1/batch-jobs/{jobId}/itemsEvery source row’s outcome, in submission order — what it produced, how big that file is, and why it failed if it did. This is where a failure is traced back to the row that caused it.
| Query parameter | Default | Description |
|---|---|---|
page | 0 | Zero-based page index |
size | 20 | Rows per page, max 100 |
curl "https://app.zandovi.com/api/v1/batch-jobs/$JOB_ID/items?page=0&size=50" \ -H "X-Api-Key: $ZANDOVI_API_KEY"{ "data": [ { "rowIndex": 0, "filename": "alice10.png", "size": 45230, "status": "SUCCESS", "error": null }, { "rowIndex": 1, "filename": "bob20.png", "size": 0, "status": "FAILED", "error": "Font not found" } ], "pagination": { "page": 0, "size": 50, "totalElements": 2, "totalPages": 1 }}rowIndexis the 0-based index of your submitted row — not a position in the archive, which a failed row is absent from. Match results back to your data on this.filenameis the entry in the result ZIP; for a failed row it’s the name that row would have produced.sizeis the rendered file’s size in bytes,0for a failed row.statusisSUCCESSorFAILED.
List jobs
Section titled “List jobs”GET /api/v1/batch-jobs| Query parameter | Default | Description |
|---|---|---|
status | — | Filter by status; repeatable (?status=PENDING&status=PROCESSING) |
source | — | API_KEY (jobs submitted via the API) or DESIGNER (jobs submitted in the app) |
page | 0 | Zero-based page index |
size | 20 | Items per page, max 100 |
Returns your jobs newest first, in the same { "data": […], "pagination": {…} } envelope as GET /shares. An unknown status, source, or out-of-range paging value returns 400 — deliberately, rather than a silently empty page.
Each row carries enough to drive a dashboard without a per-job status call:
{ "jobId": "019a5f22-4c1e-7d90-b3a8-6f2e91c04d77", "templateId": "019463b8-1234-7890-abcd-ef1234567890", "templateName": "Autumn voucher", "projectId": "019463b8-9999-7890-abcd-ef1234567890", "projectName": "Autumn campaign", "origin": "API_KEY", "apiKeyName": "nightly-sync", "status": "COMPLETED", "totalItems": 200, "completedItems": 198, "failedItems": 2, "processedItems": 200, "format": "png", "createdAt": "2026-08-11T09:00:00Z", "completedAt": "2026-08-11T09:03:41Z"}origin is API_KEY or DESIGNER — the same values the source filter takes — and apiKeyName
names the key that submitted it, so a workspace holding several keys can tell which automation did
what. apiKeyName is null when the submitting key has since been deleted; the job still lists.
projectId and projectName name the project the job’s template lives in — they are
derived from the template, not something the submission chose.
Download the results
Section titled “Download the results”GET /api/v1/batch-jobs/{jobId}/result| Query parameter | Default | Description |
|---|---|---|
delivery | url | url returns JSON with a temporary download URL; zip streams the archive directly |
With delivery=url (the default) the response is:
{ "downloadUrl": "https://…", "expiresAt": "2026-08-11T10:12:00Z"}The URL is time-limited (valid for 1 hour) and needs no authentication — fetch it from anywhere. When it expires, call the result endpoint again for a fresh one.
With delivery=zip the response body is the application/zip archive itself:
curl -o results.zip \ "https://app.zandovi.com/api/v1/batch-jobs/$JOB_ID/result?delivery=zip" \ -H "X-Api-Key: $ZANDOVI_API_KEY"Calling the result endpoint on a job that isn’t COMPLETED yet returns 409 with code: BATCH_RESULT_NOT_READY — deliberately distinct from 404, so your poller can tell wait longer apart from gone.
Result retention
Section titled “Result retention”A finished job’s archive is kept for 30 days, measured from completion. After that it is deleted
from storage and the result endpoint answers 410 with code: BATCH_RESULT_EXPIRED — again
distinct from 404, so this job’s files aged out never looks like no such job.
Expiry takes the archive and nothing else. The job’s status, its counters, and its per-row outcomes stay readable indefinitely, so a reconciliation run months later still works — it just can’t re-download the images. If you need them beyond 30 days, copy the ZIP into your own storage when the job completes.
Cancel a job
Section titled “Cancel a job”POST /api/v1/batch-jobs/{jobId}/cancelResponds 204 No Content. Cancelling an active job stops it and refunds every render that was not completed (totalItems − completedItems) back to your quota — you pay only for the images that were actually produced. Cancelling a job that already finished is a harmless no-op, also 204; it refunds nothing.
curl -X POST https://app.zandovi.com/api/v1/batch-jobs/$JOB_ID/cancel \ -H "X-Api-Key: $ZANDOVI_API_KEY"Delete a job
Section titled “Delete a job”DELETE /api/v1/batch-jobs/{jobId}Removes a finished job, its result archive, and its per-row outcome history. Responds 204 No Content. Deleting an active job is refused with 409 (code: BATCH_JOB_ACTIVE) — cancel it first. Deletion never refunds quota; cancellation is the operation that settles the bill.
Unlike retention expiry, which takes only the archive, delete takes the record too — including the row-level results your own reconciliation may still need.
Job visibility
Section titled “Job visibility”Jobs are visible to the account that created them, across all of that account’s API keys. Rotating or deleting a key does not orphan its job history — a replacement key on the same account sees, polls, and downloads the same jobs. A job created by a different account in your organization responds 404, the same as a job that doesn’t exist.
The same account sees those jobs in the app, too: the designer’s Job History lists API-submitted jobs alongside CSV ones, badged with the submitting key’s name. See Batch Generation from CSV.
Limits
Section titled “Limits”| Constraint | Value |
|---|---|
| Rows per job | Plan-dependent: Free 25 · Personal 100 · Studio 200 · Team 300 · Business & Enterprise 400 |
| Templates per job | 1 |
| Concurrent active jobs | 2 per account |
| Submission rate | 1 request/second |
| Status, list & result reads | 100 requests/second |
| Cancel & delete | 10 requests/second |
| Result download URL validity | 1 hour (re-request for a fresh URL) |
| Result archive retention | 30 days from completion, then 410 |
| Failures inlined on the status response | First 5 (the rest via /items) |
| Completion callback attempts | 5 over roughly 15 minutes |
| List & items page size | max 100 |
Errors
Section titled “Errors”| Status | code | Cause |
|---|---|---|
400 | VARIABLE_VALIDATION_FAILED | A row failed the template’s variable validation — nothing was created or charged |
400 | BATCH_ROW_LIMIT_EXCEEDED | More rows than your plan allows per job |
400 | BATCH_SIZE_EXCEEDED | The job exceeds the structural size ceiling |
400 | TEMPLATE_NOT_FOUND | The template ID doesn’t exist or isn’t accessible to this key |
400 | BATCH_INVALID_FILTER | Unknown status/source value, or invalid paging on the list or items endpoints |
400 | BATCH_INVALID_DELIVERY | delivery was something other than url or zip |
400 | BATCH_COMPLETION_CALLBACK_URL_INVALID | callbackUrl isn’t a publicly reachable https address — nothing was created or charged |
404 | BATCH_NOT_FOUND | No such job, or it belongs to another account |
404 | BATCH_RESULT_NOT_FOUND | The job completed but its archive is no longer in storage (e.g. deleted) |
410 | BATCH_RESULT_EXPIRED | The archive passed its 30-day retention window — the job itself stays readable |
409 | BATCH_IDEMPOTENCY_KEY_REUSED | The Idempotency-Key was already used for a different submission |
409 | BATCH_RESULT_NOT_READY | The job isn’t COMPLETED yet — keep polling |
409 | BATCH_JOB_ACTIVE | Delete refused because the job is still running — cancel first |
429 | BATCH_RATE_LIMIT_EXCEEDED | Monthly quota can’t cover the job, or you already have 2 active jobs |
429 | RATE_LIMIT_EXCEEDED | Short-term request rate exceeded — honor Retry-After |
500 | BATCH_DISPATCH_FAILED | The job couldn’t be handed to the renderer — the charge was refunded; retry |
Errors use the same RFC 9457 application/problem+json format as the rest of the API.
See also
Section titled “See also”- Completion Callbacks — be told when a job finishes instead of polling.
- Generating images — the synchronous single-render endpoint.
- Batch generation from CSV — the same engine, driven from a spreadsheet in the app.
- Errors, quotas & rate limits — response bodies and retry guidance.
- Usage & quotas — how batch renders are metered.