Skip to content
Back to blog
API tutorial batch webhooks

Generate Hundreds of PDFs with One API Call

pdfs.build Team

Month end comes around and you need 400 invoices. Or a course finishes and 250 people are waiting for a certificate. Or every customer gets a statement on the first of the month.

You can do this with the regular render API: loop over your records, call POST .../render for each one, and save the PDF. It works, but the loop is now your problem. You have to decide how many requests to run in parallel, retry the ones that time out, and keep track of which documents are done, all while a process sits there waiting.

The batch endpoint moves that loop to our side. You send every record in one request, get a batch id straight back, and receive one webhook when all of the documents have finished.

When to use which

You need Use
One PDF, returned in the response POST .../render (sync, the default)
One PDF, rendered in the background POST .../render with "async": true
Many PDFs from the same template POST .../batches

A batch is a set of async renders that share a template, a version and a webhook. Everything below builds on the async render docs.

1. Submit the batch

Send an items array. Each item has the same shape as a single render: a data object that matches your template’s schema.

const ORG = "YOUR_ORGANIZATION_ID";
const API = `https://api.pdfs.build/v2/organizations/${ORG}`;
const headers = {
  Authorization: `Bearer ${process.env.PDFS_API_KEY}`,
  "Content-Type": "application/json",
};

const invoices = await db.invoices.findMany({ where: { period: "2026-09" } });

const response = await fetch(`${API}/templates/invoice-primary/batches`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    items: invoices.map((invoice) => ({
      data: {
        company: invoice.customerName,
        invoice_number: invoice.number,
        line_items: invoice.lines,
      },
    })),
  }),
});

const batch = await response.json();
// { id: "batch_3f6a...", status: "processing", total: 400, statusUrl: "https://..." }
await db.batches.create({ id: batch.id, period: "2026-09" });

The response comes back as soon as the batch is queued, whatever its size. Store the id, because you will need it to match the webhook later.

A few rules apply at submit time:

2. Get notified when it’s done

Each document still renders as its own job, but you don’t get 400 webhooks. When every item has either succeeded or failed, one batch.completed event is sent to your endpoints:

{
  "id": "evt_4kR7mPq2VsXc9zNb1hTdYw",
  "type": "batch.completed",
  "createdAt": "2026-09-24T12:00:00.000Z",
  "data": {
    "batchId": "batch_3f6a...",
    "organizationId": "org_abc",
    "templateExternalId": "invoice-primary",
    "total": 400,
    "succeeded": 398,
    "failed": 2
  }
}

Webhooks are available on the Pro plan and higher. Register an endpoint under Developers → Webhooks in the dashboard. To send the event to specific endpoints only, pass webhookIds in the batch request.

Deliveries are signed with the Standard Webhooks scheme. Verify the signature before you trust the body:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, headers, secret) {
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
  const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
  const expected = createHmac("sha256", key).update(signed).digest("base64");
  return headers["webhook-signature"]
    .split(" ")
    .some((sig) => {
      const [, value] = sig.split(",");
      return value && value.length === expected.length &&
        timingSafeEqual(Buffer.from(value), Buffer.from(expected));
    });
}

Also reject deliveries whose webhook-timestamp is more than a few minutes old, and use the event id to ignore duplicates. A delivery that doesn’t get a 2xx response is retried, so your handler can see the same event twice.

3. Collect the documents

The webhook tells you the batch is finished. The batch status endpoint tells you what happened to each item:

app.post("/webhooks/pdfs", async (req, res) => {
  if (!verify(req.rawBody, req.headers, process.env.PDFS_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200); // acknowledge fast, work afterwards

  const event = JSON.parse(req.rawBody);
  if (event.type !== "batch.completed") return;

  const batch = await fetch(`${API}/batches/${event.data.batchId}`, { headers })
    .then((r) => r.json());

  for (const item of batch.items) {
    if (item.status === "success") {
      const pdf = await fetch(item.downloadUrl, { headers }).then((r) => r.arrayBuffer());
      await storage.put(`invoices/2026-09/${item.index}.pdf`, Buffer.from(pdf));
    } else {
      console.error(`Invoice ${item.index} failed: ${item.error}`);
    }
  }
});

Items come back in the order you submitted them, and index is the item’s position in your original items array. That is how you match each PDF to the record it came from. Each downloadUrl needs the same API key that submitted the batch.

In production, put the download loop on a job queue instead of running it inside the request handler. The webhook only needs a quick 200.

Not using webhooks? Poll

The batch status endpoint works without webhooks too. Poll it every few seconds until status is completed:

let batch;
do {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  batch = await fetch(`${API}/batches/${batchId}`, { headers }).then((r) => r.json());
  console.log(`${batch.succeeded + batch.failed}/${batch.total} done`);
} while (batch.status !== "completed");

pending tells you how many items are still queued or rendering, which is enough for a progress bar.

Handling failures

A failed item doesn’t stop the rest of the batch. It finishes with status: "error" and an error message, usually a compile error caused by data the template didn’t expect. The batch completes once every item is finished, whatever the mix of results, and the webhook reports the counts.

To retry, fix the data and submit the failed records as a new, smaller batch. Resubmitting a batch creates a new batch with new documents, so only resubmit the items that failed.

Limits

Limit Value
Items per batch 1 to 500
Request body 10 MB
Templates per batch 1
Quota Checked for the whole batch at submit; only successful items are charged
Webhooks Pro plan and higher

The full request and response schemas are in the API reference and the OpenAPI spec.

Back to blog