Skip to content
CastPDF
Guides

Batches: many PDFs and a ZIP

Make hundreds of PDFs from one template with one request, email each one, and download them all as a ZIP. A full walkthrough with limits and retries.

On this page

A batch makes one PDF per item from one template, in the background, and collects them in a ZIP. You send one request with a list of items (or a spreadsheet); CastPDF renders them, can email each PDF to its own recipient, and tells you when everything is done.

This guide walks through a batch from start to finish. Every field and response is listed in the batches reference.

Batch or many requests?

You can always loop over your records and call POST /v1/pdf for each one. A batch is the better fit when:

  • You make many PDFs at once, such as month-end invoices or course certificates. One request replaces hundreds, so you do not have to manage rate limits, retries and timeouts yourself.
  • You want them together. The batch ends with a ZIP of every PDF that succeeded, which you can hand to an accountant or a customer as one link.
  • Your process cannot wait. The request answers at once with 202; the PDFs are made in the background.

Stay with POST /v1/pdf when you need one PDF right now (a customer clicks "Download invoice"), when each PDF uses a different template (a batch uses one), or when you send e_invoice, which batches do not accept yet.

1. Create the batch

Send the template and one item per PDF. Each item can have its own data, filename, metadata and deliver; all four are optional.

Request
curl https://api.castpdf.com/v1/batches \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: certificates-2026-10" \
  -d '{
    "template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
    "filename_pattern": "certificate-{{ name }}",
    "deliver": { "email": { "subject": "Your certificate", "reply_to": "[email protected]" } },
    "metadata": { "course": "Safety training, October" },
    "items": [
      { "data": { "name": "Ada Lovelace" }, "deliver": { "email": { "to": "[email protected]" } } },
      { "data": { "name": "Grace Hopper" }, "deliver": { "email": { "to": "[email protected]" } } }
    ]
  }'
const res = await fetch('https://api.castpdf.com/v1/batches', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'certificates-2026-10',
  },
  body: JSON.stringify({
    template_id: '8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b',
    filename_pattern: 'certificate-{{ name }}',
    deliver: { email: { subject: 'Your certificate', reply_to: '[email protected]' } },
    metadata: { course: 'Safety training, October' },
    items: [
      { data: { name: 'Ada Lovelace' }, deliver: { email: { to: '[email protected]' } } },
      { data: { name: 'Grace Hopper' }, deliver: { email: { to: '[email protected]' } } },
    ],
  }),
});
const batch = await res.json();
if (!res.ok) throw new Error(`${batch.error.code}: ${batch.error.message}`);
console.log(batch.id, batch.status); // ..., queued
import requests

res = requests.post(
    "https://api.castpdf.com/v1/batches",
    headers={"Authorization": "Bearer YOUR_API_KEY", "Idempotency-Key": "certificates-2026-10"},
    json={
        "template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
        "filename_pattern": "certificate-{{ name }}",
        "deliver": {"email": {"subject": "Your certificate", "reply_to": "[email protected]"}},
        "metadata": {"course": "Safety training, October"},
        "items": [
            {"data": {"name": "Ada Lovelace"}, "deliver": {"email": {"to": "[email protected]"}}},
            {"data": {"name": "Grace Hopper"}, "deliver": {"email": {"to": "[email protected]"}}},
        ],
    },
    timeout=60,
)
batch = res.json()
if not res.ok:
    raise RuntimeError(f"{batch['error']['code']}: {batch['error']['message']}")
print(batch["id"], batch["status"])  # ..., queued
202
{
  "id": "6a2f9c1e-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
  "status": "queued",
  "livemode": true,
  "source": "api",
  "template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
  "template_version": 4,
  "total": 2,
  "queued": 2,
  "processing": 0,
  "succeeded": 0,
  "failed": 0,
  "canceled": 0,
  "created_at": "2026-10-03T10:15:02.123Z",
  "completed_at": null,
  "zip": null,
  "metadata": { "course": "Safety training, October" }
}

The answer comes at once, with a Location header pointing at the batch. The template version is fixed now: the pinned version (or the current one, or the template_version you send), so editing the template while the batch runs changes nothing. mode, format, orientation and margins work as on POST /v1/pdf and apply to every item. Your metadata comes back with the batch; each item's own metadata comes back in its webhook events.

File names

Without a name, items are called after the template and their number, padded to the same width: in a batch of 250 items of the template "Certificate", Certificate-001.pdf to Certificate-250.pdf. filename_pattern builds names from each item's data instead, with {{ field }} placeholders (dotted paths such as {{ customer.name }} work; a missing value prints nothing). An item's own filename wins over the pattern. Names are cleaned like any file name (characters other than letters, digits, ., _ and - become -, and .pdf is added), so certificate-{{ name }} gives certificate-Ada-Lovelace.pdf. When two names in the ZIP are the same, the later ones get -2, -3 and so on.

Emailing each PDF

The batch's deliver.email holds the defaults (subject, message, reply_to); an item's own fields win. Only items that end up with a to are emailed, so you can email some items and not others. Each email is sent as soon as its PDF is ready. Who can be emailed, the daily limits and what the email looks like are on the email delivery page. The limits are checked for the whole batch when you create it, so a batch that would pass your daily email limit is refused before anything is rendered.

Test batches

Run the batch with a test key, or add "test": true with a live key, to make a test batch: every PDF is watermarked, nothing is counted, test webhooks are sent, and the ZIP is kept for 1 day. Test batches email only verified addresses of your own team. Try a batch in test mode first, open a few PDFs, then run it live.

2. Wait for it to finish

The batch moves from queued to processing to completed (or canceled). There are two ways to know when it is done.

With a webhook (the Starter, Growth, Pro and Scale plans): subscribe an endpoint to batch.completed. It arrives once, with the finished batch and its ZIP link, so you never poll. Each item also sends pdf.created or pdf.failed with the batch's id in batch_id. See webhooks.

By polling GET /v1/batches/:id: the counts (queued, processing, succeeded, failed, canceled) show the progress. Poll every few seconds, not in a tight loop: each call counts against your rate limit.

Poll a batch
async function waitForBatch(id) {
  for (;;) {
    const res = await fetch(`https://api.castpdf.com/v1/batches/${id}`, {
      headers: { Authorization: 'Bearer YOUR_API_KEY' },
    });
    const batch = await res.json();
    if (!res.ok) throw new Error(`${batch.error.code}: ${batch.error.message}`);
    if (batch.status === 'completed' || batch.status === 'canceled') return batch;
    console.log(`${batch.succeeded + batch.failed} of ${batch.total} done`);
    await new Promise((r) => setTimeout(r, 5000));
  }
}

const done = await waitForBatch('6a2f9c1e-3b4d-4e5f-8a9b-0c1d2e3f4a5b');
console.log(done.succeeded, done.failed, done.zip?.url);
import time

import requests


def wait_for_batch(batch_id: str) -> dict:
    while True:
        res = requests.get(
            f"https://api.castpdf.com/v1/batches/{batch_id}",
            headers={"Authorization": "Bearer YOUR_API_KEY"},
            timeout=30,
        )
        batch = res.json()
        if not res.ok:
            raise RuntimeError(f"{batch['error']['code']}: {batch['error']['message']}")
        if batch["status"] in ("completed", "canceled"):
            return batch
        print(f"{batch['succeeded'] + batch['failed']} of {batch['total']} done")
        time.sleep(5)


done = wait_for_batch("6a2f9c1e-3b4d-4e5f-8a9b-0c1d2e3f4a5b")
print(done["succeeded"], done["failed"], (done["zip"] or {}).get("url"))

completed means every item either succeeded or failed: a batch with failed items is still completed. Always check failed.

3. Check the items

GET /v1/batches/:id/items lists the items in the order you sent them, with each PDF's link and, for a failed item, its error:

curl
curl "https://api.castpdf.com/v1/batches/6a2f9c1e-3b4d-4e5f-8a9b-0c1d2e3f4a5b/items?limit=100" \
  -H "Authorization: Bearer YOUR_API_KEY"
200
{
  "data": [
    {
      "index": 0,
      "id": "0b6c4d2e-8f1a-4b3c-9d5e-7f6a8b9c0d1e",
      "status": "succeeded",
      "filename": "certificate-Ada-Lovelace.pdf",
      "error_code": null,
      "error_message": null,
      "document_url": "https://api.castpdf.com/v1/files/0b6c4d2e-8f1a-4b3c-9d5e-7f6a8b9c0d1e?exp=1791972902&sig=Lk2w…"
    },
    {
      "index": 1,
      "id": "1c7d5e3f-9a2b-4c4d-8e6f-8a7b9c0d1e2f",
      "status": "failed",
      "filename": "certificate-Grace-Hopper.pdf",
      "error_code": "plan_limit_reached",
      "error_message": "You have used all documents included in your plan for this period. Upgrade to continue.",
      "document_url": null
    }
  ],
  "next_after": null
}

index is the item's position (0 for the first item or spreadsheet row), so you can match an item to your own record. id is the item's document id, for GET /v1/pdf/:id, which also shows whether its email was sent. A page has up to 100 items (default 20); when next_after is not null, pass it as after to get the next page.

4. Retry failed items

A batch does not retry an item that failed for a reason in your data or account, such as plan_limit_reached when your documents ran out, or content_lost when a layout cannot be split across pages. Fix the cause, then send the failed items again as a new batch, using their index to find their data. Only failures that a second try can fix (a render that timed out, a busy service or an internal error) are retried automatically, as described below.

5. Download the ZIP

Once the batch is finished, zip holds a signed link to a ZIP of every PDF that succeeded, in item order:

JSON
"zip": {
  "url": "https://api.castpdf.com/v1/batches/6a2f9c1e-3b4d-4e5f-8a9b-0c1d2e3f4a5b/zip?exp=1791973277&sig=Qm3t…",
  "expires_at": "2026-10-10T10:21:17.001Z",
  "bytes": 96804
}

The link needs no API key, so you can hand it to a browser or a person. It downloads batch-<id>.zip and works until expires_at: the ZIP is kept as long as your plan keeps PDFs (Free 1 day, Starter 7 days, Growth 30 days, Pro 90 days and Scale 90 days; test batches 1 day). After that, the link answers file_expired.

zip stays null when no item succeeded, and also when the ZIP could not be made: it would pass your workspace's stored-PDF limit (the ZIP is a second copy of every PDF) or 4 GB. The PDFs themselves are still there: download them one by one with each item's document_url.

Canceling a batch

POST /v1/batches/:id/cancel cancels the items that have not started. Items already rendering finish. The batch then ends as canceled, with a ZIP of what succeeded and a batch.completed event. Canceled items are not counted. Calling cancel again, or on a finished batch, changes nothing and returns the batch.

Limits

LimitValue
Items per batchFree 25, Starter 500, Growth 1,000, Pro 2,500 and Scale 5,000 (more is invalid_request with details.limit: "batch_items")
Background PDFs waiting or rendering at once, batch items includedFree 50, Starter 1,000, Growth 2,000, Pro 5,000 and Scale 10,000 (more is rate_limited with details.limit: "open_jobs")
Test-mode background PDFs in 24 hoursFree 100, Starter 2,000, Growth 4,000, Pro 10,000 and Scale 20,000 (more is rate_limited with details.limit: "test_jobs_per_day")
Request body15 MB
filename_pattern200 characters
metadata, for the batch and for each item20 keys and 8 KB as JSON

Items render with your workspace's concurrent render slots (Free 1, Starter 2, Growth 3, Pro 4 and Scale 4). When your plan has more than one, a batch leaves one free, so your direct POST /v1/pdf requests are not stuck behind it. Each PDF has the same limits as any other: 50 pages, 40 MB and 30 seconds.

Retries and counting

  • Counting. Each item that succeeds in a live batch counts as a document, exactly like a POST /v1/pdf request. Failed and canceled items are not counted, and test batches are never counted. If your documents run out during a batch, the remaining items fail with plan_limit_reached (or spending_cap_reached when overage reaches your spending cap); see usage and billing.
  • Automatic retries. An item that times out, meets a busy service or fails with an internal error is tried again: 3 attempts in all, with waits of 30 seconds and 2 minutes. Other errors fail the item at once.
  • No double PDFs. A batch keeps going through restarts of our servers, and continues where it stopped without making or counting a PDF twice.

Idempotency

Send an Idempotency-Key header with POST /v1/batches, and a retry of the same request can never create a second batch. The same key and the same body within 24 hours return the first batch (202, with its current state), whether it is still running or finished. The same key with a different body is idempotency_mismatch (422), and a second request while the first is still being created is idempotency_conflict (409): retry it a moment later. Use a key that names the job, such as certificates-2026-10. See idempotency for the general rules.

Next steps

Updated Something unclear? [email protected], a real person replies.