Building a Document Automation Pipeline with a REST API

A practical guide to designing a document automation pipeline around a REST API — covering triggers, templating, rendering, storage, and delivery.

pdfautomationrest-apinode.jsbackend

Building a Document Automation Pipeline with a REST API

Most SaaS products eventually need to generate documents: invoices, contracts, reports, shipping labels, statements. What starts as "just render a PDF" quickly grows into a pipeline with templates, data validation, retries, storage, and delivery. If you architect it well up front, you avoid a mess of cron jobs and one-off scripts later.

This post walks through building a document automation pipeline around a REST API. The examples use Node.js and Kamy as the rendering service, but the pattern applies to any HTTP-based PDF generator.

The pipeline, at a glance

A typical automated document flow has five stages:

  1. Trigger — an event fires (order paid, report scheduled, contract signed).
  2. Data assembly — fetch and normalize the data needed for the document.
  3. Render — send data + template to a PDF API.
  4. Store — persist the file somewhere durable (S3, GCS, R2).
  5. Deliver — email it, push a webhook, or expose a download URL.

Each stage should be independently retryable. Coupling them tightly is the fastest way to end up debugging why an invoice email failed at 3 a.m.

Stage 1: The trigger

Triggers usually come from three sources: a webhook, a queue message, or a scheduled job. Regardless of source, normalize them into a single internal event:

ts
type DocumentJob = { id: string; type: "invoice" | "contract" | "report"; entityId: string; requestedAt: string; };

Push these jobs onto a queue (SQS, BullMQ, whatever you already use). Do not render synchronously in the request handler that received the webhook — PDF generation can take seconds, and you want retries for free.

ts
app.post("/webhooks/stripe", async (req, res) => { const event = req.body; if (event.type === "invoice.paid") { await queue.add("render-invoice", { id: crypto.randomUUID(), type: "invoice", entityId: event.data.object.id, requestedAt: new Date().toISOString(), }); } res.status(200).end(); });

Stage 2: Data assembly

The worker pulls the job and gathers everything the template needs. Keep this step pure: given an entityId, produce a fully-formed data object. No side effects.

ts
async function buildInvoiceData(invoiceId: string) { const invoice = await db.invoices.findById(invoiceId); const customer = await db.customers.findById(invoice.customerId); const lineItems = await db.lineItems.findByInvoice(invoiceId); return { invoiceNumber: invoice.number, issuedAt: invoice.issuedAt.toISOString(), customer: { name: customer.name, email: customer.email, address: customer.address, }, items: lineItems.map((li) => ({ description: li.description, quantity: li.quantity, unitPrice: li.unitPrice, total: li.quantity * li.unitPrice, })), total: invoice.total, currency: invoice.currency, }; }

Validate this payload before sending it downstream. A schema check with Zod or JSON Schema catches missing fields before you burn a render call.

Stage 3: Rendering

This is where the REST API earns its keep. You send JSON, you get a PDF (or a URL to one). With Kamy, a render call looks like:

ts
import { kamy } from "@kamydev/sdk"; const result = await kamy.documents.render({ template: "invoice-v2", data: invoiceData, format: "pdf", }); // result.url is a signed URL to the generated PDF

If you're calling the HTTP API directly:

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

A couple of practical notes:

  • Version your templates. Never edit invoice in place. Ship invoice-v2, roll consumers over, then retire the old one. This is the single biggest source of "the PDF looks weird" tickets.
  • Set a timeout. Renderers can be slow when a template pulls remote assets. A 30-second HTTP timeout with a bounded retry policy is a good default.
  • Idempotency. Include your job id as an idempotency key so retries don't produce duplicate files.
ts
const result = await kamy.documents.render( { template: "invoice-v2", data: invoiceData }, { idempotencyKey: job.id, timeoutMs: 30_000 } );

Stage 4: Storage

Even if your PDF API stores the file, you almost always want your own copy. It gives you control over retention, access policies, and outages.

ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; const s3 = new S3Client({ region: "us-east-1" }); async function persist(job: DocumentJob, pdfBytes: Buffer) { const key = `invoices/${job.entityId}/${job.id}.pdf`; await s3.send( new PutObjectCommand({ Bucket: "docs-prod", Key: key, Body: pdfBytes, ContentType: "application/pdf", }) ); return key; }

Record the storage key against the source entity in your database. That way you can regenerate on demand or serve the exist