Building a Document Automation Pipeline with a REST API

A practical walkthrough for building a document automation pipeline using a REST API, covering templates, queueing, storage, and delivery.

pdfautomationrest-apinode.jsdocument-generation

Building a Document Automation Pipeline with a REST API

Document generation is one of those problems that looks simple until it isn't. You start with "just render this HTML to PDF" and six months later you're maintaining a headless Chrome cluster, a template registry, a retry queue, and a signed URL service. This post walks through how to design a document automation pipeline around a REST API — the stages, the trade-offs, and the pieces you shouldn't build yourself.

The shape of a document pipeline

Almost every document automation system has the same four stages:

  1. Trigger — an event causes a document to be generated (order placed, contract signed, report scheduled).
  2. Render — a template is combined with data to produce a file (PDF, DOCX, etc.).
  3. Store — the file is persisted somewhere durable with access controls.
  4. Deliver — the file is sent, downloaded, or linked from another system.

The reason to break this into stages is that each one fails differently. Rendering is CPU-heavy and slow. Storage is cheap but needs careful access control. Delivery is network-bound and often user-facing. Treating them as one blob of code leads to timeouts, retries that duplicate work, and users staring at spinners.

Why a REST API in the middle

A document rendering service exposed over HTTP gives you clean separation. Your application code stays focused on business logic. The rendering layer — templates, fonts, headless browsers, PDF libraries — lives behind an API boundary you can scale and version independently.

Whether you host that service yourself or use something like Kamy, the interface tends to look the same: POST data, get back a URL or binary.

http
POST /v1/documents/render HTTP/1.1 Host: api.kamy.dev Authorization: Bearer sk_live_xxx Content-Type: application/json { "template": "invoice_v3", "data": { "invoiceNumber": "INV-2024-00412", "customer": { "name": "Acme Corp" }, "lineItems": [ { "description": "Pro plan", "amount": 4900 } ] }, "options": { "format": "pdf" } }

Triggering renders from your application

Don't call the render endpoint synchronously from a user-facing request handler unless you're sure the render will complete in under a second or two. Instead, enqueue a job.

Here's a minimal pattern using a background worker:

ts
// api/routes/orders.ts app.post("/orders", async (req, res) => { const order = await db.orders.create(req.body); await queue.enqueue("generate-invoice", { orderId: order.id }); res.status(201).json(order); });

The worker handles the actual render call:

ts
// workers/generate-invoice.ts import { kamy } from "@kamydev/sdk"; queue.process("generate-invoice", async (job) => { const order = await db.orders.findById(job.data.orderId); const pdf = await kamy.documents.render({ template: "invoice_v3", data: serializeOrder(order), options: { format: "pdf" }, }); await db.invoices.create({ orderId: order.id, url: pdf.url, expiresAt: pdf.expiresAt, }); });

This gives you retries for free, keeps your HTTP response times low, and isolates document failures from order creation.

Templates: version them like code

The biggest mistake teams make with document pipelines is treating templates as runtime configuration. A template is code. It has bugs, it needs review, and it needs versioning.

A few rules that save pain later:

  • Pin a template version per environment. invoice_v3 in staging shouldn't silently become invoice_v4 in production.
  • Keep template source in your repo, deploy it to the rendering service via CI.
  • Snapshot-test rendered output against known inputs.

A simple deploy step might look like:

bash
kamy templates push ./templates/invoice.html \ --name invoice \ --version 4 \ --env production

Then your render call references the exact version:

ts
await kamy.documents.render({ template: "invoice@4", data: { ... }, });

Handling the long tail: retries, idempotency, storage

Three concerns tend to bite you in production.

Idempotency. If your worker crashes after rendering but before saving the URL, a retry will render the document twice. Pass an idempotency key:

ts
await kamy.documents.render({ template: "invoice@4", data, idempotencyKey: `invoice-${order.id}`, });

The API returns the same document for the same key, so retries are safe.

Storage lifetime. Rendered documents often come back as short-lived signed URLs. If you need the invoice available for seven years for tax purposes, download the bytes and store them in your own bucket:

ts
const response = await fetch(pdf.url); const buffer = Buffer.from(await response.arrayBuffer()); await s3.putObject({ Bucket: "invoices", Key: `${order.id}.pdf`, Body: buffer, ContentType: "application/pdf", });

Observability. Log the template version, input hash, and render duration. When a customer reports that their invoice from six months ago has the wrong tax rate, you'll want to know exactly which template version produced it.

ts
logger.info("document.rendered", { orderId: order.id, template: "invoice@4", inputHash: hash(data), durationMs: Date.now() - start, });

Delivery

The last mile depends on the use case. For user downloads, serve a redirect to the signed URL. For email attachments, download the bytes and attach them — don't embed signed URLs in emails that may be opened days later. For webhoo