Bulk Image Generation Without the Loop: One POST, One ZIP

  • 22 Aug, 2026
Bulk Image Generation Without the Loop: One POST, One ZIP

You have a template and a few hundred rows of data. A voucher per customer, a badge per attendee, that kind of job. The obvious move is a for loop around a render endpoint, and the obvious move works right up until it doesn’t.

The loop hits the per-second rate limit, so you add a sleep. Some requests time out, so you add retries. A retry lands on a request that actually succeeded, so now you’ve paid for the same image twice and you add your own deduplication layer. Request 217 of 400 fails on a bad row, so you write bookkeeping to know where to resume. Then you collect 400 responses into files, name them, and zip them yourself.

None of that is image generation. All of it is your code now.

Zandovi’s batch endpoint exists so you don’t write any of it. You POST the rows once, the service renders them all, and you download a single ZIP. This post walks through the whole flow and the details that make it safe to call from automation: idempotent retries, validation before billing, and refunds when you cancel.

The shape of a batch job

Three requests, start to finish:

  1. POST /api/v1/batch-jobs with a template ID and a rows array. You get 202 Accepted and a job ID back immediately.
  2. GET /api/v1/batch-jobs/{jobId} until the status turns COMPLETED.
  3. GET /api/v1/batch-jobs/{jobId}/result for a download URL, or the ZIP bytes directly.

Each row is one rendered image, and each row costs one render from your monthly quota, the same as one call to the single-render endpoint. A 400-row job is 400 renders, charged when the job is accepted.

Submitting the job

Every row is an object mapping variable names to values, exactly the shape the single-render endpoint takes in variables. Format and render options are set once, on the job, because rows differ only in data:

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: spring-vouchers-2026" \
  -d '{
    "template": {
      "templateId": "019463b8-1234-7890-abcd-ef1234567890",
      "format": "png",
      "options": { "scale": 2 },
      "output": { "filenamePattern": "{voucher_code}" }
    },
    "rows": [
      { "first_name": "Alice", "voucher_code": "ALICE10" },
      { "first_name": "Bob",   "voucher_code": "BOB20" }
    ]
  }'
{
  "jobId": "019a5f22-4c1e-7d90-b3a8-6f2e91c04d77",
  "status": "PENDING",
  "totalItems": 2,
  "createdAt": "2026-08-11T10:00:00Z"
}

The detail that does the most work here: validation runs before billing. Every row is checked against the template’s variable rules at submission. If row 217 is missing a required value or carries barcode data that won’t encode, the whole submission is rejected with a 400 naming the row, no job is created, and nothing is charged. You fix the row and resubmit. The half-finished batch, the one where you’re not sure which rows rendered before it died, doesn’t exist in this flow.

Polling until it’s done

curl https://app.zandovi.com/api/v1/batch-jobs/$JOB_ID \
  -H "X-Api-Key: $ZANDOVI_API_KEY"

The status response carries totalItems, completedItems, failedItems, and an errors array with per-row messages, so a progress bar is free. Statuses run PENDING, PROCESSING, and then one of COMPLETED, FAILED, or CANCELLED. The inline errors array is capped at the first five failures — failedItems carries the true count, and GET /batch-jobs/{jobId}/items carries the whole list, one entry per submitted row with its filename, size and error.

Status reads sit in a separate, much higher rate tier than submissions (100 per second versus 1 per second), so polling every couple of seconds is fine. In Node, the whole flow is about twenty lines:

const BASE = "https://app.zandovi.com/api/v1";
const headers = {
  "X-Api-Key": process.env.ZANDOVI_API_KEY!,
  "Content-Type": "application/json",
};

async function renderBatch(templateId: string, rows: Record<string, string>[]) {
  const submit = await fetch(`${BASE}/batch-jobs`, {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": `batch-${templateId}-${rows.length}` },
    body: JSON.stringify({ template: { templateId, format: "png" }, rows }),
  });
  const { jobId } = await submit.json();

  while (true) {
    await new Promise((r) => setTimeout(r, 2000));
    const job = await (await fetch(`${BASE}/batch-jobs/${jobId}`, { headers })).json();
    if (job.status === "COMPLETED") break;
    if (job.status === "FAILED" || job.status === "CANCELLED")
      throw new Error(`Batch ${job.status}: ${JSON.stringify(job.errors)}`);
  }

  const result = await (
    await fetch(`${BASE}/batch-jobs/${jobId}/result`, { headers })
  ).json();
  return result.downloadUrl; // valid for 1 hour; fetch again for a fresh one
}

One wrinkle worth knowing: calling the result endpoint before the job finishes returns 409 with code: BATCH_RESULT_NOT_READY, deliberately distinct from 404. Your code can tell “keep waiting” from “this job is gone” without guessing.

Or let the job call you

The loop above is fine for a script you’re watching. For anything that runs unattended, hand the submission a callbackUrl and skip the waiting entirely:

await fetch(`${BASE}/batch-jobs`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": `batch-${templateId}-${rows.length}` },
  body: JSON.stringify({
    template: { templateId, format: "png" },
    rows,
    callbackUrl: "https://example.com/hooks/zandovi",
  }),
});

When the job reaches COMPLETED, FAILED or CANCELLED, Zandovi POSTs a summary to that URL — deliveryId, jobId, status and the final counters. No download URL is in the body on purpose: result links expire after an hour and a retried delivery would arrive carrying a dead one, so your handler calls the result endpoint for a fresh link when it’s actually ready to collect.

Verify the delivery before you act on it. Each one carries X-Zandovi-Signature: t=<unix-seconds>,v1=<hex>, where the hex is HMAC-SHA256(secret, t + "." + rawBody). The secret is per organization, lives in the app under Settings → API Keys, and is not your API key. Three things break verification quietly: re-serializing the body before hashing it (read the raw bytes), comparing with === instead of a constant-time compare, and accepting stale timestamps. Delivery is at-least-once with five attempts over roughly fifteen minutes, so deduplicate on deliveryId and return 2xx as soon as you’ve durably accepted the payload — a handler that renders or emails inline will hit the ten-second timeout and be retried even though it worked.

If a delivery gives up, the job says so: GET /batch-jobs/{jobId} carries a completionCallback object with the attempt count and the last error. Worth checking, because bounded retries mean an endpoint that refused every attempt otherwise looks identical to a job that never finished. The full rules are in Completion Callbacks.

The retry that doesn’t cost you twice

The single-render endpoint has no server-side deduplication. Call it twice and you pay twice, which is why the automation platform guide spends three paragraphs on guarding your own retries.

Batch submissions fix this properly. Send any string in the Idempotency-Key header, and a retry of the same submission with the same key returns the original job: the same jobId comes back, and nothing renders or bills twice. This holds even when two retries race each other, because the deduplication is enforced by a unique index in the database rather than by application code checking first and inserting second.

Two rules keep it honest. One key belongs to one submission, so reusing a key with a different template, row count, format, or filename pattern is rejected with 409 BATCH_IDEMPOTENCY_KEY_REUSED instead of silently returning the wrong job. And the comparison is structural: the server matches the submission’s shape, not every row byte, so don’t recycle yesterday’s key for today’s data. Derive the key from the thing the batch represents, like payroll-aug-2026 or an order ID, and reuse it only when retrying that exact batch.

If your code has any retry path at all, and it does, send the header.

Files named from your data

By default the ZIP contains image-000.png, image-001.png, and so on, which is fine until you need to match file 217 to a person. Set a filename pattern instead:

"output": { "filenamePattern": "{voucher_code}" }

Any row variable works as a placeholder, plus {index}, {timestamp}, and {random}. Values are lowercased and unsafe characters become hyphens. Skip the extension; the format’s own extension is appended, so {voucher_code}.png would come out as alice10.png.png.

If two rows produce the same filename, the later ones get a suffix, ticket.png then ticket-2.png. That rule sounds minor and isn’t: it means an archive can never silently contain fewer files than the rows you paid for.

Cancelling, and what it refunds

POST /api/v1/batch-jobs/{jobId}/cancel stops an active job and refunds every render that hadn’t completed, totalItems minus completedItems, back to your quota. Submit 400 rows, cancel at 150 done, get 250 renders back. Cancelling a job that already finished is a no-op that refunds nothing, and the accounting is race-safe: a cancel that arrives just as the job completes doesn’t refund work that was actually delivered.

Deleting is separate. DELETE removes a finished job and its archive but never refunds; cancellation is the operation that settles the bill.

When the loop is still right

A batch job is for many images from one template, now. It’s the wrong shape for one image per event: a voucher the moment someone signs up, an OG image when a post publishes, a ticket when someone pays. Those want the single-render endpoint called from the event, one POST, bytes back. The OG image guide and the spreadsheet-to-API section of the certificates guide cover that shape.

The dividing line: if you’d be writing a loop, it should be a batch job. If there’s no loop, it shouldn’t be.

What this doesn’t do

One template per job. Rows vary the data, not the design. Rendering three different templates is three jobs.

Row caps are per plan: 25 rows per job on Free, 100 on Personal, 200 on Studio, 300 on Team, 400 on Business. Bigger datasets get split into multiple jobs, and you can have two active jobs at a time.

The result URL expires after an hour. That’s a fresh-URL-on-request design, not image hosting; ask again for another one. The archive behind it is kept for 30 days after the job finishes, then deleted — the download answers 410 BATCH_RESULT_EXPIRED rather than a bare 404, and the job’s counters and per-row outcomes stay readable indefinitely. Copy the ZIP into your own storage if you need it longer. If you need individually hosted images rather than a ZIP, that’s what share links are for.

What this costs

One render per row, from the same monthly pool as every other render. The free plan’s 100 renders cover a real test batch of 25 rows four times over. Personal at $29/month is 5,000 renders with 100-row jobs, and the caps rise from there. Cancelled rows come back; the quota only keeps what was actually rendered.


The batch rendering reference documents all seven endpoints, every error code, and the full filename pattern rules, and Completion Callbacks covers signing and retries. For why the API is shaped this way, see the launch note.