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:
- Trigger — an event fires (order paid, report scheduled, contract signed).
- Data assembly — fetch and normalize the data needed for the document.
- Render — send data + template to a PDF API.
- Store — persist the file somewhere durable (S3, GCS, R2).
- 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:
tstype 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.
tsapp.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.
tsasync 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:
tsimport { 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:
httpPOST /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
invoicein place. Shipinvoice-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
idas an idempotency key so retries don't produce duplicate files.
tsconst 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.
tsimport { 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