How to generate PDFs from database rows with a reusable template
The pattern is: query your rows, map each row into the JSON shape your template expects, then POST to /api/v1/pdf once per row with a unique idempotencyKey (the row's primary key works well) so retries never produce duplicate documents. Store the returned PDF bytes wherever you'd store any generated file — S3, a blob column, local disk — keyed to that same row id. This works the same whether you're generating 10 invoices or 10,000, because idempotency is enforced server-side against the key you send, not by anything client-side you have to coordinate yourself.
The pattern in one sentence
Query rows → map each row to { templateId, data } → POST one request per row with idempotencyKey: row.id → save the PDF bytes back next to the row (or in object storage, keyed by id). Everything below is that loop, filled in with real code. It's the same shape whether the rows are invoices, certificates, or monthly reports — only the template and the mapping function change.
Step 1: query the rows
Nothing special here — pull whatever rows need a document generated this run. A common filter is "invoices issued today" or "rows where pdf_generated_at is null":
select id, customer_name, customer_email, amount_cents, currency, due_date, line_items
from invoices
where pdf_generated_at is null
order by created_at
limit 500;Step 2: map each row to your template's data shape
A template stored via the Docweave dashboard or API takes a templateId plus a data object that fills its placeholders. Write one small function that turns a database row into that data object — this is the only part of the pipeline that's specific to your schema:
function toTemplateData(row) {
return {
invoiceNumber: `INV-${row.id}`,
customerName: row.customer_name,
customerEmail: row.customer_email,
amount: (row.amount_cents / 100).toFixed(2),
currency: row.currency,
dueDate: row.due_date.toISOString().slice(0, 10),
lineItems: row.line_items, // already an array of { description, qty, price }
};
}If you're rendering invoices, receipts, or quotes specifically, the invoices, receipts, and quotes use-case pages have field-by-field template examples you can start from instead of designing the layout from scratch.
Step 3: POST one request per row, with an idempotency key
Send Authorization: Bearer with your API key, and Accept: application/pdf so the response body is raw PDF bytes rather than a JSON wrapper. Set idempotencyKey to the row's primary key (or a composite like invoice-${row.id}-v2 if you regenerate versions) — if a batch job crashes halfway and you re-run it, rows that already succeeded return the same cached PDF instead of billing and rendering again:
import { writeFile } from 'node:fs/promises';
async function renderRow(row) {
const res = await fetch('https://docweave.dev/api/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DOCWEAVE_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/pdf',
},
body: JSON.stringify({
source: { templateId: 'tmpl_invoice_v1', data: toTemplateData(row) },
idempotencyKey: `invoice-${row.id}`,
}),
});
if (!res.ok) {
const err = await res.json(); // 502 = render failed, body has { error }
console.error(`row ${row.id} failed:`, err.error);
return { ok: false, id: row.id };
}
const buffer = Buffer.from(await res.arrayBuffer());
await writeFile(`./out/invoice-${row.id}.pdf`, buffer);
return { ok: true, id: row.id };
}Then loop over the rows with a small concurrency cap rather than firing all requests at once — a for loop with Promise.all in batches of 5-10 is enough for most workloads:
const CONCURRENCY = 8;
for (let i = 0; i < rows.length; i += CONCURRENCY) {
const batch = rows.slice(i, i + CONCURRENCY);
const results = await Promise.all(batch.map(renderRow));
const succeeded = results.filter(r => r.ok).map(r => r.id);
await db.query(
'update invoices set pdf_generated_at = now() where id = any($1)',
[succeeded]
);
}That's the whole thing: query, map, render, mark done. The full request/response contract, error shape, and language-specific snippets are on the Node.js integration guide and the general API docs.
Testing a single row from the CLI
Before wiring up the batch job, it's worth confirming one row renders correctly with curl, saving straight to a file:
curl https://docweave.dev/api/v1/pdf \
-X POST \
-H "Authorization: Bearer $DOCWEAVE_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/pdf" \
-d '{
"source": { "templateId": "tmpl_invoice_v1", "data": { "invoiceNumber": "INV-1042" } },
"idempotencyKey": "invoice-1042"
}' \
--output invoice-1042.pdfNo stored template yet? Inline HTML works the same way
If you haven't built a reusable template through the dashboard, you can render straight from HTML instead of templateId — swap source: { templateId, data } for source: { html: renderRowToHtml(row) }, where renderRowToHtml is any templating function you already have (a JSX-to-string render, a Handlebars compile, a plain template literal). The rest of the loop — the idempotency key, the Accept: application/pdf header, the per-row save — is identical. See the HTML-to-PDF API glossary entry and programmatic PDF generation for the tradeoffs between inline HTML and stored templates.
Running this from an agent instead of a script
If the batch job is being driven by an AI agent rather than a cron job — for example, an agent that decides which rows need documents and generates them on demand — the same template + data pair works through the open-source MCP server instead of raw fetch:
await generate_pdf({
source: { templateId: 'tmpl_invoice_v1', data: toTemplateData(row) },
outputPath: `./out/invoice-${row.id}.pdf`,
});npx @docweave/mcp exposes generate_pdf as a tool an agent can call directly, writing the file to outputPath instead of returning bytes for you to buffer manually. More on agent-driven document generation on the PDF API for AI agents page.
Handling partial failures without losing the whole batch
A render never throws mid-batch — a failed render comes back as a normal HTTP response (502) with an error field, not a dropped connection. That's why the loop above checks res.ok per row instead of wrapping everything in one try/catch: one malformed row (a null due_date, an oversized image URL) shouldn't stop the other 499 from generating. Log the failed ids, fix the underlying data, and re-run just those rows with the same idempotencyKey — no risk of double-billing the rows that already succeeded.
A few things that matter once you're past a handful of rows
- Keep
idempotencyKeydeterministic and tied to the row (not a random UUID generated fresh each run), or retries stop being safe. - Cap concurrency (5-10 in-flight requests) rather than firing thousands of requests at once — it's gentler on your own database connection pool too.
- If you regenerate a document after the underlying row changes, change the idempotency key (append a version or updated_at timestamp) so you get a fresh render instead of the cached one.
- Check pricing if you're estimating cost for a large one-time backfill — it's per document, not per page, so a 40-page report costs the same as a 1-page receipt.
FAQ
Should I generate all PDFs in one request or one request per row?
One request per row. There's no batch endpoint — each render is a single document — but that's usually what you want anyway, since it lets you retry or fix individual failed rows without redoing the whole set.
What should I use as the idempotencyKey for database rows?
The row's primary key is usually enough (invoice-${row.id}). If you regenerate a document after the row is edited, include a version marker or updated_at timestamp in the key so the edited version isn't served from cache as the old one.
Can I store the generated PDF back in the same database?
Yes — either as a blob/bytea column next to the row, or more commonly as a URL to object storage (S3, R2) with the object key derived from the row id, which keeps your database rows small.
What if my template needs data from more than one table?
Join the tables in your query (or run a second lookup) before calling the mapping function — the API doesn't care where the data object came from, only that it matches the placeholders your template expects.
Related
Generate your first PDF in minutes.
Get an API key