Batch Jobs Are Now in the Public API

  • 23 Aug, 2026
Batch Jobs Are Now in the Public API

Until this week, batch generation was an app feature. You could upload a CSV in the editor and download a ZIP, but from code the only way to render two hundred images was two hundred POST requests. Our own docs told you to loop the generate endpoint. Several of our own guides taught the workarounds: guard your retries, watch the rate limit, keep your own bookkeeping.

That’s over. Batch jobs are now a first-class resource in the public API, on every plan, with the same API key you already have:

POST   /api/v1/batch-jobs                submit rows, get a job back
GET    /api/v1/batch-jobs                list your jobs
GET    /api/v1/batch-jobs/{jobId}        poll status and progress
GET    /api/v1/batch-jobs/{jobId}/items  what each submitted row produced
GET    /api/v1/batch-jobs/{jobId}/result download URL or ZIP stream
POST   /api/v1/batch-jobs/{jobId}/cancel stop it, refund what didn't render
DELETE /api/v1/batch-jobs/{jobId}        remove a finished job and its archive

One submission carries one template and up to 400 rows, each row becomes one image, and the job hands back a ZIP. The tutorial post walks the flow end to end with code; this note is about the decisions behind it, because a few of them are the reason to use it over the loop you already have.

Retries can’t double-bill you

Bulk submissions are exactly the requests that get retried: they’re big, they sit on the wire longer, and they run from job queues that re-execute on timeout. So the submit endpoint takes an Idempotency-Key header. Retry with the same key and you get the original job back instead of a duplicate, and your quota doesn’t move.

The implementation detail worth trusting: deduplication is a unique index in the database, not an application-level check. Two concurrent retries of the same submission race all the way to the insert, the database picks one winner, and the loser returns the winner’s job and refunds itself. There is no window where both retries create a job.

Reusing a key for a different submission fails loudly with a 409 instead of returning something that isn’t what you sent. One key, one batch.

Validation runs before billing

Every row is validated against the template’s variable rules at submission time. One bad row rejects the whole submission with a 400 that names it, no job is created, and no quota is charged. The failure mode where a job dies at row 217 and you reconstruct what rendered from your logs is not a failure mode this API has.

Cancel refunds exactly what didn’t render

A job with N rows charges N renders up front, which keeps quota accounting simple and predictable. The other half of that promise is the cancel endpoint: stop an active job and every render that hadn’t completed comes back, totalItems minus completedItems. The arithmetic runs on a conditional update inside the database, so a cancel that loses a race against the job’s completion refunds nothing rather than refunding work you actually received.

Cancelling a finished job is a harmless no-op. Deleting never refunds; cancelling is what settles the bill.

The ZIP can’t silently lose your files

Filename patterns let you name output files from your data, "filenamePattern": "{voucher_code}", instead of image-000.png. Which raises a question nobody wants answered in production: what happens when two rows produce the same name?

They get suffixed, ticket.png, ticket-2.png, case-insensitively, after the names are scrubbed of everything a filesystem might object to, including Windows-reserved names and path tricks. The invariant is blunt: a collision, an accented name, or a hostile value in a data column can rename a file but never drop one.

The archive is flat — images only, no folder, no manifest — and a row that failed to render simply isn’t in it. That’s what /items is for: every submitted row in submission order, with the filename it produced, its size, and the error if it has one. Row-level bookkeeping reads without downloading anything, and it stays readable long after the archive itself has aged out.

Job history survives key rotation

Jobs belong to the account that created them, not to the API key. Rotate a key, and the replacement sees, polls, and downloads every job the old key created. Rotating credentials is something you should be able to do casually; losing your job history was a bad reason not to.

Polling that isn’t punished

Submission is rate-limited to 1 per second. Status and result reads run at 100 per second, a separate tier, so a fleet of workers each polling their job every two seconds doesn’t compete with, or get mistaken for, submission traffic. Poll freely; that’s what the tier is for.

Or don’t poll at all

A submission can carry a callbackUrl, and when the job reaches a terminal state we POST a summary to it — job id, terminal status, final counters — so an automation can submit and go quiet instead of holding a loop open for the length of the render. All three terminal states are delivered. Someone who stopped polling because a callback was promised has to hear about a failure and a cancellation too, not only a success.

Two decisions in there are worth stating plainly. The payload carries no download URL: result links live an hour, and a delivery retried ninety minutes later would arrive holding a dead one, so you ask for a fresh link when your handler is ready to collect. And every delivery is signed — X-Zandovi-Signature, HMAC-SHA256 over the timestamp and the raw body, keyed by a per-organization secret that is deliberately not your API key. A leaked API key can’t forge callbacks; a leaked signing secret can’t render anything.

Delivery is at-least-once with a deliveryId that’s stable across retries, so deduplicate on it. Retries are bounded — five attempts over roughly fifteen minutes, which outlives a rolling deploy but refuses to become a permanent queue pointed at a dead URL. Because they’re bounded, whether a delivery ever landed is readable on the job itself as completionCallback, with the attempt count and the last error. Without that, an endpoint that refused every attempt would look exactly like a job that never finished.

There is no subscription API behind this, no event catalogue, and no endpoint registry to keep in sync. The URL belongs to the submission that wants to hear back.

What’s not in v1

One template per job; rows vary the data, not the design. Row caps are per plan, 25 on Free up to 400 on Business, and two jobs can be active at once. Result archives are kept for 30 days after a job finishes, after which the download reports itself expired rather than missing — the job, its counters and its per-row outcomes stay readable. If any of those are the thing between you and using it, tell us.

If you’re currently looping

Keep the loop for event-driven single images; that shape is still right. For everything that renders a set, the migration is mechanical: the variables object you send per request becomes one entry in rows, the render options move up a level onto the job, and the response handling collapses into poll-then-download.

# before: 400 requests, your retry logic, your rate limiting, your ZIP
# after:
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: badge-run-2026-08-22" \
  -d '{ "template": { "templateId": "'$TEMPLATE_ID'", "format": "png" },
        "rows": '"$(cat rows.json)"' }'

The CSV tab in the app isn’t going anywhere; it’s the same engine with a spreadsheet in front of it, and the when-to-use-which question has the same answer as before: humans upload files, backends POST rows.


Full reference, including every error code and limit: Batch Rendering in the docs. The OpenAPI spec already includes the seven new operations if you’d rather import them into Postman. Callbacks have their own page: Completion Callbacks.