Skip to content

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-jobs

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

  1. SubmitPOST /api/v1/batch-jobs with a template reference and your rows. Returns 202 with a jobId.
  2. PollGET /api/v1/batch-jobs/{jobId} until status is terminal. Or supply a callbackUrl at submission and be told instead.
  3. DownloadGET /api/v1/batch-jobs/{jobId}/result for a download URL or the ZIP itself.
  4. Reconcile (optional) — GET /api/v1/batch-jobs/{jobId}/items for what each row produced.
StatusMeaning
PENDINGAccepted, waiting for a renderer
PROCESSINGRendering is underway
COMPLETEDEvery row processed — results are ready
FAILEDThe job failed
CANCELLEDYou cancelled it

PENDING and PROCESSING are active; the other three are terminal.

POST /api/v1/batch-jobs

Headers

HeaderRequiredDescription
X-Api-KeyyesYour API key
Content-Typeyesapplication/json
Idempotency-KeynoClient-chosen string (max 255 chars) that makes retries safe — see Idempotency

Body

FieldTypeRequiredDescription
template.templateIdUUIDyesThe template to render — one template per job
template.formatstringnopng (default), jpeg, webp, or pdf
template.optionsobjectnoSame render options as the generate endpoint: scale, quality, outputDpi, backgroundColor
template.output.filenamePatternstringnoNaming pattern for the files in the ZIP — see Filename patterns
rowsarrayyesNon-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.
callbackUrlstringnoPublicly 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.

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

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.

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.

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 202 with 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 409 with code: 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.

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:

PlaceholderValue
{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 format extension is appended automatically. {discount_code} yields alice10.png; {discount_code}.png would yield alice10.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.

GET /api/v1/batch-jobs/{jobId}
Terminal window
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
}
  • downloadUrl and downloadUrlExpiresAt are populated only once status is COMPLETED.
  • errors lists per-row failures as { "itemIndex": …, "error": … }itemIndex refers to your submitted rows array. It is capped at the first 5 failures; failedItems carries the true count and GET /batch-jobs/{jobId}/items carries the whole list.
  • processedItems tracks progress while the job runs. On a COMPLETED or FAILED job it equals completedItems + failedItems; on a CANCELLED or timed-out job it keeps the count of rows that were rendered before the job stopped.
  • completionCallback is null unless the submission supplied a callbackUrl. When it did, it reports the delivery: { deliveryId, callbackUrl, status, attempts, deliveredAt, lastError } with status one of PENDING, DELIVERED, or FAILED. 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.
GET /api/v1/batch-jobs/{jobId}/items

Every 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 parameterDefaultDescription
page0Zero-based page index
size20Rows per page, max 100
Terminal window
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 }
}
  • rowIndex is 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.
  • filename is the entry in the result ZIP; for a failed row it’s the name that row would have produced.
  • size is the rendered file’s size in bytes, 0 for a failed row.
  • status is SUCCESS or FAILED.
GET /api/v1/batch-jobs
Query parameterDefaultDescription
statusFilter by status; repeatable (?status=PENDING&status=PROCESSING)
sourceAPI_KEY (jobs submitted via the API) or DESIGNER (jobs submitted in the app)
page0Zero-based page index
size20Items 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.

GET /api/v1/batch-jobs/{jobId}/result
Query parameterDefaultDescription
deliveryurlurl 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:

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

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.

POST /api/v1/batch-jobs/{jobId}/cancel

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

Terminal window
curl -X POST https://app.zandovi.com/api/v1/batch-jobs/$JOB_ID/cancel \
-H "X-Api-Key: $ZANDOVI_API_KEY"
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.

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.

ConstraintValue
Rows per jobPlan-dependent: Free 25 · Personal 100 · Studio 200 · Team 300 · Business & Enterprise 400
Templates per job1
Concurrent active jobs2 per account
Submission rate1 request/second
Status, list & result reads100 requests/second
Cancel & delete10 requests/second
Result download URL validity1 hour (re-request for a fresh URL)
Result archive retention30 days from completion, then 410
Failures inlined on the status responseFirst 5 (the rest via /items)
Completion callback attempts5 over roughly 15 minutes
List & items page sizemax 100
StatuscodeCause
400VARIABLE_VALIDATION_FAILEDA row failed the template’s variable validation — nothing was created or charged
400BATCH_ROW_LIMIT_EXCEEDEDMore rows than your plan allows per job
400BATCH_SIZE_EXCEEDEDThe job exceeds the structural size ceiling
400TEMPLATE_NOT_FOUNDThe template ID doesn’t exist or isn’t accessible to this key
400BATCH_INVALID_FILTERUnknown status/source value, or invalid paging on the list or items endpoints
400BATCH_INVALID_DELIVERYdelivery was something other than url or zip
400BATCH_COMPLETION_CALLBACK_URL_INVALIDcallbackUrl isn’t a publicly reachable https address — nothing was created or charged
404BATCH_NOT_FOUNDNo such job, or it belongs to another account
404BATCH_RESULT_NOT_FOUNDThe job completed but its archive is no longer in storage (e.g. deleted)
410BATCH_RESULT_EXPIREDThe archive passed its 30-day retention window — the job itself stays readable
409BATCH_IDEMPOTENCY_KEY_REUSEDThe Idempotency-Key was already used for a different submission
409BATCH_RESULT_NOT_READYThe job isn’t COMPLETED yet — keep polling
409BATCH_JOB_ACTIVEDelete refused because the job is still running — cancel first
429BATCH_RATE_LIMIT_EXCEEDEDMonthly quota can’t cover the job, or you already have 2 active jobs
429RATE_LIMIT_EXCEEDEDShort-term request rate exceeded — honor Retry-After
500BATCH_DISPATCH_FAILEDThe 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.