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:
- Up to 500 items per request. For more, split your records into chunks of 500 and submit one batch per chunk.
- All or nothing on quota. The whole batch is checked against your monthly render quota before anything is queued. If it doesn’t fit, you get a
429and no documents are rendered. You will never end up with half a month of invoices. - One version for every document. The template version is resolved when you submit. If someone promotes a new version while your batch is running, your remaining documents still use the version you started with. (The one exception is a template that has never had a version promoted, which renders its live draft.)
- Only successes are charged. Each item that renders counts as one render. Failed items don’t count.
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.