Building a Document Automation Pipeline with a REST API
Every business app eventually needs to spit out documents: invoices, contracts, shipping labels, monthly statements, compliance reports. The naive approach is to render one PDF inline when a user clicks a button. That works until you're generating ten thousand statements at month-end and your web workers grind to a halt.
A proper document automation pipeline separates what to render from when it renders and where it ends up. This post walks through building one with a REST API as the rendering primitive.
The four stages
A robust pipeline has four discrete stages:
- Trigger — an event says "generate this document"
- Compose — gather the data and pick a template
- Render — turn template + data into a PDF
- Deliver — store it, send it, or hand it back
Keeping these separate means you can retry, cache, and scale each independently.
Stage 1: Trigger
Triggers come from three places: user actions (a button click), scheduled jobs (nightly billing), or system events (a webhook from Stripe). Whatever the source, don't render inline. Push a job onto a queue.
ts// api/routes/invoices.ts import { queue } from "../lib/queue"; app.post("/invoices/:id/pdf", async (req, res) => { const job = await queue.add("render-invoice", { invoiceId: req.params.id, userId: req.user.id, }); res.status(202).json({ jobId: job.id }); });
Returning 202 Accepted with a job ID lets the client poll or subscribe over websockets. Your API stays responsive under load.
Stage 2: Compose
The worker pulls the job and gathers everything the template needs. This is where you fetch from your database, resolve foreign keys, format currency, and localize dates. Do it before you call the rendering API — don't make the rendering layer do business logic.
ts// workers/renderInvoice.ts export async function composeInvoiceData(invoiceId: string) { const invoice = await db.invoice.findUnique({ where: { id: invoiceId }, include: { customer: true, lineItems: true }, }); return { invoiceNumber: invoice.number, issuedAt: formatDate(invoice.createdAt, invoice.customer.locale), customer: { name: invoice.customer.name, address: invoice.customer.address, }, lineItems: invoice.lineItems.map((li) => ({ description: li.description, quantity: li.quantity, total: formatMoney(li.amount, invoice.currency), })), total: formatMoney(invoice.total, invoice.currency), }; }
The output is a flat, presentation-ready JSON payload. If a designer changes the template later, you shouldn't need to touch this function.
Stage 3: Render
Now hand that payload to a rendering API. You can build your own with Puppeteer or wkhtmltopdf, but running headless Chromium at scale is a full-time job — memory leaks, font handling, sandboxing, cold starts. Most teams outsource this.
Using Kamy, a render call looks like this:
tsimport { kamy } from "@kamydev/sdk"; const data = await composeInvoiceData(invoiceId); const pdf = await kamy.documents.render({ template: "invoice-v2", data, options: { format: "A4", filename: `invoice-${data.invoiceNumber}.pdf`, }, }); // pdf.url is a signed URL valid for one hour // pdf.bytes is the raw buffer if you asked for it
Or the raw HTTP call, if you're not on Node:
httpPOST /v1/documents HTTP/1.1 Host: api.kamy.dev Authorization: Bearer sk_live_... Content-Type: application/json { "template": "invoice-v2", "data": { "invoiceNumber": "INV-1042", "...": "..." }, "options": { "format": "A4" } }
A few things to design for at this stage:
- Idempotency. Pass an idempotency key so retries don't produce duplicate documents.
- Template versioning. Reference templates by version (
invoice-v2), not by name alone. You do not want a template edit to silently change last month's statements. - Timeouts. Rendering should be fast (sub-second for most documents), but set a hard timeout and let the queue retry.
Stage 4: Deliver
Once you have the PDF, decide where it belongs. Common patterns:
- Persistent storage — push to S3 with a deterministic key like
invoices/{tenant}/{year}/{id}.pdf - Email delivery — attach or link from a transactional email
- User download — return the signed URL to the client polling your job endpoint
tsimport { S3 } from "@aws-sdk/client-s3"; const s3 = new S3({}); const key = `invoices/${tenantId}/${invoice.id}.pdf`; await s3.putObject({ Bucket: "acme-documents", Key: key, Body: pdf.bytes, ContentType: "application/pdf", Metadata: { invoiceId: invoice.id, templateVersion: "invoice-v2", }, }); await db.invoice.update({ where: { id: invoice.id }, data: { pdfKey: key, pdfGeneratedAt: new Date() }, });
Storing the template version alongside the file is worth doing. Six months from now when someone asks "why does this invoice look different?", the metadata tells you.
Handling failure
Every stage can fail. The queue handles retries for transient errors,