Generate hundreds of documents from one Word template and one spreadsheet. This guide covers the whole job: prepare the data, send the batch, poll the job, download the ZIP, and read skippedRecords.
The API reference documents each endpoint on its own. This page runs them in order.
templateId.X-API-Key.One row per document. One column per template variable. Column headers must match the variable names in your template exactly, including case.
| invoice_no | customer | due_date | amount_due |
|---|---|---|---|
| INV-1001 | Alice Ltd | 14 Mar 2026 | £1,240.00 |
| INV-1002 | Bob Ltd | 21 Mar 2026 | £980.50 |
Four rules save most of the debugging time:
£1,240.00 and 14 Mar 2026, not a raw number or a date serial. The engine inserts what you send, as text.#N/A, #REF!, #VALUE! and similar cause the row to be skipped. See step 6.Check the row count against your plan limit before you send.
| Plan | Max records per batch |
|---|---|
| Pro | 100 |
| Enterprise | 500 |
| Custom | 1,000 |
The ZIP itself is capped at 50 MB, and each document at 10 MB. Split very large runs into several batches.
Each spreadsheet row becomes one object in the records array. A record with a repeating table looks like this:
{
"invoice_no": "INV-1001",
"customer": "Alice Ltd",
"due_date": "14 Mar 2026",
"amount_due": "£1,240.00",
"items": [
{ "desc": "Design retainer", "qty": "1", "line_total": "£1,000.00" },
{ "desc": "Hosting", "qty": "4", "line_total": "£240.00" }
]
}
In the template, items is a loop: wrap the repeating table row in {{#items}} and {{/items}}, then use the item's own fields inside it.
curl -X POST https://api.bulkrender.com/api/documents/generate-batch \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Idempotency-Key: invoices-2026-03-14-run1" \
-d '{
"templateId": "c5f2e8a1-...",
"records": [ ... ],
"outputFormat": "pdf"
}' What comes back depends on size.
| Records | Status | Meaning |
|---|---|---|
| 10 or fewer | 201 Created | Done already. The body carries batchId, count, and a ZIP url. Skip to step 5. |
| More than 10 | 202 Accepted | Queued. The body carries a jobId and a starting progress. Go to step 4. |
A 202 body:
{
"jobId": "9f31c0b2-...",
"status": "processing",
"progress": { "completed": 0, "total": 50, "failed": 0 }
} Always send an Idempotency-Key from automated pipelines
A retry with the same key returns the first result instead of generating and charging twice.
curl https://api.bulkrender.com/api/documents/batch-jobs/9f31c0b2-... \ -H "X-API-Key: YOUR_API_KEY"
{
"status": "success",
"data": {
"jobId": "9f31c0b2-...",
"status": "processing",
"progress": { "completed": 32, "total": 50, "failed": 0 },
"createdAt": "2026-03-14T09:12:04.881Z",
"completedAt": null
}
} data.status moves through pending, processing, then completed or failed.
url, the signed ZIP link.error_message on the response and fix the cause before resubmitting.Poll every 3 to 5 seconds. Batches run with a 5 minute timeout and up to 5 documents in flight at once, so a 500 record PDF job takes minutes, not seconds. Do not poll in a tight loop.
The endpoint also accepts a synchronous batchId, so one polling routine handles both sizes. For a sync batch it returns "status": "completed" with "completedSynchronously": true.
The url is a signed link, valid for 1 hour by default and 24 hours at most.
curl -L -o invoices.zip "https://download.bulkrender.com/...batch-9f31c0b2.zip?X-Amz-Expires=3600&..."
If the link expires, poll the job again for a fresh one. Generated documents auto-expire after 3 days by default, so archive the ZIP on your side.
BulkRender validates rows before generating. A row holding a spreadsheet error value is skipped, never generated, and never charged.
When any row is skipped, the initial 201 or 202 response includes two extra fields:
| Field | Meaning |
|---|---|
| originalCount | How many records you sent. |
| skippedRecords | One entry per dropped row: { "index": 2, "reason": "..." } |
{
"jobId": "9f31c0b2-...",
"status": "processing",
"originalCount": 50,
"progress": { "completed": 0, "total": 48, "failed": 0 },
"skippedRecords": [
{ "index": 2, "reason": "Cell contains spreadsheet error value #N/A" },
{ "index": 17, "reason": "Cell contains spreadsheet error value #REF!" }
]
} Three things to know:
index is the position in the array you sent, counted from 0. Map it back to your spreadsheet row before you report the error to a user.422 with the same skippedRecords list. Nothing generates and nothing is charged.Retrying corrected rows: use a new Idempotency-Key
A partial-skip 201 is cached like any other success, so reusing the old key returns the stale result instead of regenerating. 422 responses are never cached.
| Symptom | Cause and fix |
|---|---|
| 400 Bad Request | Missing templateId or records, payload over 1 MB, or batch over your plan limit. Split the run or trim the payload. |
| 403 Forbidden | Not enough credits, or the key's role is too low. Top up credits, or use a key with generate rights. |
| 422 on the whole batch | Every row failed validation. Clean the error values and resubmit with a new key. |
| Blanks in the output | A column header does not match the template variable. Match names exactly, including case. |
| Totals empty or wrong | The template expects a computed value. Pre-compute totals in the data. |
| Dates or currency look raw | Values were sent as numbers. Pre-format them as strings. |
| Retry returned the old ZIP | The Idempotency-Key was reused. Use a new key. |
Create an account, upload your first template, and generate your first document in under five minutes.