Building a Document Automation Pipeline with a REST API

A practical guide to designing a document automation pipeline using a REST API, covering ingestion, templating, rendering, storage, and delivery.

pdfautomationrest-apinode.jsdocument-generation

Building a Document Automation Pipeline with a REST API

Document generation rarely stays simple. What starts as "render an invoice as PDF" turns into batch jobs, signed URLs, retries, multi-tenant templates, and webhook callbacks. If you're building this in-house, you'll end up writing a pipeline whether you planned to or not. This post walks through how to structure that pipeline around a REST API, what to handle yourself, and what to offload.

The shape of a document pipeline

Most document automation systems have four stages:

  1. Trigger — an event arrives (order placed, contract signed, report scheduled).
  2. Compose — gather data, pick a template, merge them.
  3. Render — convert the merged template into a final artifact (usually PDF).
  4. Deliver — store it, email it, push it to S3, or hand back a URL.

Each stage has its own failure modes. A clean pipeline isolates them so you can retry independently.

Stage 1: Triggering jobs

Triggers usually come from your application's domain events. Push them onto a queue rather than calling your render service directly — render jobs are slow (hundreds of milliseconds to several seconds) and you don't want them blocking a checkout request.

ts
// In your order service await queue.publish("documents.render", { type: "invoice", orderId: order.id, tenantId: order.tenantId, requestedBy: user.id, });

A worker consumes this and orchestrates the rest. If you're on a managed platform, this could be SQS + Lambda, Cloud Tasks, or BullMQ on Redis.

Stage 2: Composing the payload

The worker's job is to gather everything the template needs. Resist the urge to make the template fetch its own data — keep rendering pure. Pass a fully-resolved JSON payload.

ts
async function buildInvoicePayload(orderId: string) { const [order, customer, items] = await Promise.all([ db.orders.findById(orderId), db.customers.findByOrderId(orderId), db.lineItems.findByOrderId(orderId), ]); return { invoiceNumber: order.invoiceNumber, issuedAt: order.createdAt.toISOString(), customer: { name: customer.name, email: customer.email }, items: items.map((i) => ({ description: i.description, qty: i.quantity, price: i.unitPrice, total: i.quantity * i.unitPrice, })), total: order.total, currency: order.currency, }; }

Two things matter here: schema stability and idempotency. Version your payload schemas the same way you'd version an API. And include a deterministic key (like orderId + version) so re-runs don't produce duplicates.

Stage 3: Rendering

This is where most teams hit a wall. PDF rendering means either:

  • Running headless Chromium yourself (Puppeteer/Playwright), or
  • Calling a rendering API.

Running Chromium works, but it comes with operational overhead: memory leaks, font installation, sandboxing on containers, slow cold starts, and concurrency limits. If rendering isn't your product, this isn't where you want to spend engineering time.

A REST-based rendering service like Kamy handles the runtime and gives you a single HTTP call:

http
POST /v1/documents/render HTTP/1.1 Host: api.kamy.dev Authorization: Bearer $KAMY_API_KEY Content-Type: application/json { "template": "invoice-v2", "data": { "invoiceNumber": "INV-1042", "total": 199.0 }, "output": { "format": "pdf" } }

The response gives you a signed URL or the binary directly:

json
{ "id": "doc_01HRZ9...", "url": "https://files.kamy.dev/doc_01HRZ9.pdf?sig=...", "expiresAt": "2025-03-12T14:00:00Z" }

From the worker:

ts
import { kamy } from "@kamydev/sdk"; async function renderInvoice(payload: InvoicePayload) { const doc = await kamy.documents.render({ template: "invoice-v2", data: payload, idempotencyKey: `invoice:${payload.invoiceNumber}`, }); return doc; }

An idempotencyKey is critical. Workers retry. Without it, a transient network blip turns into duplicate invoices.

Stage 4: Delivery and storage

Once rendered, decide where the artifact lives. Signed URLs from the rendering service are fine for short-lived access (email links, in-app downloads). For compliance or long-term storage, copy the file into your own bucket and record metadata.

ts
async function persistDocument(doc: RenderedDoc, ctx: JobContext) { const file = await fetch(doc.url).then((r) => r.arrayBuffer()); const key = `tenants/${ctx.tenantId}/invoices/${doc.id}.pdf`; await s3.putObject({ Bucket: "my-documents", Key: key, Body: Buffer.from(file), ContentType: "application/pdf", }); await db.documents.insert({ id: doc.id, tenantId: ctx.tenantId, type: "invoice", s3Key: key, renderedAt: new Date(), }); }

Then fan out: send the email, fire a webhook, notify the user.

Handling failure

Each stage needs its own retry semantics:

  • Compose — retry on transient DB errors, fail hard on missing data.
  • Render — retry