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

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.


