The problem with "generate PDFs at scale"

Most teams start with a one-at-a-time approach: loop through rows in a database, call a PDF generation function for each row, save the file. This works for ten invoices. It does not work for ten thousand. The bottleneck is usually one of three things: the rendering engine is too slow (if you're using Puppeteer or wkhtmltopdf), the loop isn't parallelized so you wait for each PDF before generating the next, or there is no error handling so one bad row kills the whole run.

Typsetter's batch endpoint solves all three problems. Rendering is handled by Typst (a compiled engine written in Rust), parallelization happens on our infrastructure, and individual row errors are captured without stopping the batch. Let's build it.

Prerequisites

Step 1: Prepare your CSV file

The batch endpoint accepts a CSV where each row becomes one PDF. Column headers must match the variable names in your template exactly. For the invoice-professional template, the expected variables are:

CSV ColumnTemplate VariableExample Value
client_name{{ client_name }}Acme Corporation
client_email{{ client_email }}billing@acme.com
invoice_number{{ invoice_number }}INV-2026-00001
invoice_date{{ invoice_date }}2026-02-01
due_date{{ due_date }}2026-03-01
service_description{{ service_description }}Web Development
amount{{ amount }}4500.00
currency{{ currency }}USD

Here is a minimal example CSV:

invoices.csv
client_name,client_email,invoice_number,invoice_date,due_date,service_description,amount,currency Acme Corporation,billing@acme.com,INV-2026-00001,2026-02-01,2026-03-01,Web Development,4500.00,USD Beta Systems Ltd,accounts@betasys.io,INV-2026-00002,2026-02-01,2026-03-01,API Integration,2800.00,USD Gamma Analytics,finance@gamma.co,INV-2026-00003,2026-02-01,2026-03-01,Data Pipeline,6200.00,EUR # ... 9,997 more rows
Tip

Typsetter auto-detects CSV delimiters (comma, semicolon, tab) and character encodings (UTF-8, UTF-8 BOM, Latin-1). You do not need to pre-process your export from Excel or Google Sheets.

Step 2: Upload the batch job via API

The batch endpoint is a multipart form upload. You send the CSV file, the template slug, and optional configuration parameters. The response contains a job_id you use to poll for status.

submit-batch.sh
curl -X POST https://api.typsetter.dev/v1/batch \ -H "Authorization: Bearer ts_live_sk_YOUR_KEY" \ -F "template=invoice-professional" \ -F "file=@invoices.csv" \ -F "webhook_url=https://your-app.com/hooks/batch-complete" \ -F "filename_column=invoice_number" # Response: # { # "job_id": "batch_01J8XMKP4Q2VWZR", # "status": "queued", # "row_count": 10000, # "estimated_seconds": 180 # }

The filename_column parameter tells Typsetter which CSV column to use as the PDF filename inside the ZIP. Using invoice_number means each PDF will be named INV-2026-00001.pdf, INV-2026-00002.pdf, and so on.

Step 3: Poll for job status (or use the webhook)

For large batches, rendering is asynchronous. Poll the status endpoint or configure a webhook URL (shown above) to be notified on completion.

poll-status.sh
curl https://api.typsetter.dev/v1/batch/batch_01J8XMKP4Q2VWZR \ -H "Authorization: Bearer ts_live_sk_YOUR_KEY" # { # "job_id": "batch_01J8XMKP4Q2VWZR", # "status": "processing", # "progress": { "completed": 4820, "failed": 3, "total": 10000 }, # "started_at": "2026-02-18T09:14:22Z", # "estimated_remaining_seconds": 92 # }

Step 4: Full Node.js implementation

Here is a complete Node.js script that submits the batch, polls for completion with exponential backoff, and downloads the ZIP when finished.

batch-invoices.mjs
import fs from 'node:fs'; import path from 'node:path'; import FormData from 'form-data'; import fetch from 'node-fetch'; const API_KEY = process.env.TYPSETTER_API_KEY; const BASE_URL = 'https://api.typsetter.dev/v1'; async function submitBatch(csvPath, template) { const form = new FormData(); form.append('template', template); form.append('file', fs.createReadStream(csvPath)); form.append('filename_column', 'invoice_number'); const res = await fetch(`${BASE_URL}/batch`, { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, ...form.getHeaders() }, body: form, }); if (!res.ok) throw new Error(`Submit failed: ${res.status} ${await res.text()}`); return res.json(); } async function waitForCompletion(jobId) { let delay = 5000; // start polling every 5s while (true) { await new Promise(r => setTimeout(r, delay)); const res = await fetch(`${BASE_URL}/batch/${jobId}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } }); const data = await res.json(); console.log(`[${new Date().toISOString()}] ${data.progress?.completed ?? 0}/${data.progress?.total ?? '?'} complete`); if (data.status === 'completed' || data.status === 'failed') return data; delay = Math.min(delay * 1.5, 30000); // back off up to 30s } } async function downloadZip(jobId, outPath) { const res = await fetch(`${BASE_URL}/batch/${jobId}/download`, { headers: { 'Authorization': `Bearer ${API_KEY}` } }); if (!res.ok) throw new Error('Download failed'); const dest = fs.createWriteStream(outPath); res.body.pipe(dest); await new Promise((res, rej) => { dest.on('finish', res); dest.on('error', rej); }); console.log(`ZIP saved to ${outPath}`); } // Main const { job_id, row_count } = await submitBatch('./invoices.csv', 'invoice-professional'); console.log(`Batch submitted: ${job_id} (${row_count} rows)`); const result = await waitForCompletion(job_id); console.log(`Completed: ${result.progress.completed} ok, ${result.progress.failed} failed`); await downloadZip(job_id, `./invoices-${job_id}.zip`);
Dependencies

Run npm install form-data node-fetch before executing this script. On Node.js 21+, you can use the built-in fetch and FormData globals and skip those imports.

Step 5: Set up a webhook for completion notification

If your batch takes 3–5 minutes, you don't want to poll — you want to be notified. Pass a webhook_url when submitting the batch, and Typsetter will POST a signed JSON payload to your endpoint when the job finishes.

webhook-handler.mjs (Express)
import express from 'express'; import crypto from 'node:crypto'; const app = express(); app.use(express.raw({ type: '*/*' })); app.post('/hooks/batch-complete', (req, res) => { // Verify HMAC-SHA256 signature const sig = req.headers['x-typsetter-signature']; const hmac = crypto.createHmac('sha256', process.env.TYPSETTER_WEBHOOK_SECRET) .update(req.body).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(hmac))) { return res.status(401).send('Bad signature'); } const payload = JSON.parse(req.body.toString()); console.log(`Batch ${payload.job_id} done: ${payload.progress.completed} PDFs`); // Trigger your own download / notification logic here res.sendStatus(200); }); app.listen(3000);

Tips for large batches

Keep templates simple for speed

Templates that embed large images or use complex Typst scripting will render slower. For batch use, strip unnecessary decorations. A clean invoice template with a logo URL (fetched once, cached) will render in 200–300ms per page.

Use the filename_column parameter

Without it, PDFs are named sequentially (1.pdf, 2.pdf). With filename_column=invoice_number, each file is named by its invoice number, making the ZIP directly usable without renaming.

Handle failed rows

When a batch completes, the progress.failed count tells you how many rows errored. Download the error report from GET /v1/batch/{job_id}/errors to get a CSV of failed rows with error messages. Fix the data and resubmit those rows as a smaller batch.

get-errors.sh
curl https://api.typsetter.dev/v1/batch/batch_01J8XMKP4Q2VWZR/errors \ -H "Authorization: Bearer ts_live_sk_YOUR_KEY" \ -o failed-rows.csv # Returns CSV with original row data + error_message column

Plan limits

Batch processing is available on all paid plans. The Growth plan supports batches up to 1,000 rows. Pro and above supports up to 10,000 rows. If you need more, contact us for enterprise limits.

What 10,000 PDFs looks like in practice

In testing on the Pro plan infrastructure, a batch of 10,000 single-page invoices using the invoice-professional template takes approximately 4 minutes and 20 seconds end-to-end. The resulting ZIP is around 180MB (individual PDFs average 18KB each). That works out to roughly 38 PDFs rendered per second across the parallel processing pool.

For comparison, generating the same 10,000 invoices sequentially with Puppeteer at 4.2 seconds each would take 11.6 hours. Even with a 10-worker parallel setup, you would wait over an hour. The architecture matters.

Rate limits

The batch endpoint counts against your plan's monthly PDF quota. A batch of 10,000 rows consumes 10,000 PDF credits. Check your usage on the dashboard before submitting large batches to avoid surprises.

Ready to generate your first batch?

Start with 100 free PDFs per month. No credit card required. Upgrade when you need more.