Every SaaS generates documents. Invoices after payments. Contracts after sign-up. Reports at the end of the month. Receipts after refunds.

At first, each of these is a one-off implementation. A function here, a template there, some email-sending code scattered across three services. It works until it does not.

Then you need to change the invoice layout and realize the generation logic is duplicated in four places. Or a customer never received their contract and you have no way to trace what happened. Or the sales team wants a new document type and the estimated delivery time is "two sprints."

This is what happens when document generation grows without a pipeline.

The Problem: Ad-Hoc Document Generation

Most teams build document generation reactively. A feature needs a PDF, so a developer writes the code to generate it. Another feature needs a different PDF, so another developer writes similar but slightly different code.

After a year, you have:

  • Multiple generation patterns: Some documents use Puppeteer, others use a PDF library, one uses a third-party API. Each has different error handling, different retry logic, different logging.
  • Templates mixed with business logic: The HTML template is hardcoded in a controller. Changing the layout requires a code deploy.
  • No visibility: When a document fails to generate or deliver, nobody knows until a customer complains. There is no centralized log of what was generated, when, or for whom.
  • Duplicated effort: Every new document type requires building the same plumbing: gather data, render template, generate PDF, deliver to user.

Why a Pipeline Matters

A document automation pipeline is not about over-engineering. It is about having one clear path that every document follows:

Event → Template Selection → Variable Mapping → PDF Generation → Delivery

When every document goes through the same pipeline, you get:

  • Consistency: Every document is generated, logged, and delivered the same way.
  • Traceability: You can trace any document from the triggering event to the final delivery.
  • Speed: Adding a new document type means defining a template and a mapping, not building new infrastructure.
  • Reliability: Retry logic, error handling, and monitoring live in one place.

Designing the Pipeline

Here is the architecture, step by step.

Step 1: Define Document Events

Start by listing every event in your application that should trigger a document:

// document-events.js
const DOCUMENT_EVENTS = {
  PAYMENT_COMPLETED: {
    templateId: "invoice-template-uuid",
    description: "Generate invoice after successful payment",
  },
  CONTRACT_SIGNED: {
    templateId: "contract-template-uuid",
    description: "Generate signed contract PDF",
  },
  MONTH_END_REPORT: {
    templateId: "monthly-report-uuid",
    description: "Generate monthly usage report",
  },
  SUBSCRIPTION_CANCELLED: {
    templateId: "cancellation-confirmation-uuid",
    description: "Generate cancellation confirmation",
  },
};

This registry is your single source of truth for what documents your application produces.

Step 2: Build Variable Mappers

Each event carries raw data. A mapper transforms that data into template variables:

// mappers/invoice-mapper.js
async function mapInvoiceVariables(eventData) {
  const customer = await db.getCustomer(eventData.customerId);
  const payment = await db.getPayment(eventData.paymentId);
  const lineItems = await db.getLineItems(payment.id);

  return {
    customer_name: customer.name,
    customer_email: customer.email,
    invoice_number: payment.invoiceNumber,
    invoice_date: formatDate(payment.createdAt),
    line_items: lineItems.map((item) => ({
      description: item.description,
      quantity: item.quantity,
      unit_price: formatCurrency(item.unitPrice),
      total: formatCurrency(item.quantity * item.unitPrice),
    })),
    subtotal: formatCurrency(payment.subtotal),
    tax: formatCurrency(payment.tax),
    total: formatCurrency(payment.total),
  };
}

// mappers/index.js
const mappers = {
  PAYMENT_COMPLETED: mapInvoiceVariables,
  CONTRACT_SIGNED: mapContractVariables,
  MONTH_END_REPORT: mapReportVariables,
  SUBSCRIPTION_CANCELLED: mapCancellationVariables,
};

Mappers isolate data-fetching logic from generation logic. When your database schema changes, you update the mapper, not the generation code.

Step 3: The Generation Service

The core of the pipeline. It takes an event, maps variables, calls the API, and logs everything:

// services/document-pipeline.js
const DOCUMENT_EVENTS = require("./document-events");
const mappers = require("./mappers");

async function processDocumentEvent(eventType, eventData) {
  const config = DOCUMENT_EVENTS[eventType];
  if (!config) {
    throw new Error(`Unknown document event: ${eventType}`);
  }

  const mapper = mappers[eventType];
  if (!mapper) {
    throw new Error(`No mapper for event: ${eventType}`);
  }

  // Step 1: Map variables
  const variables = await mapper(eventData);

  // Step 2: Generate PDF
  const response = await fetch(
    "https://api.transactional.dev/v1/generate",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-api-token": process.env.TRANSACTIONAL_API_KEY,
      },
      body: JSON.stringify({
        documentId: config.templateId,
        variables,
      }),
    }
  );

  const { url } = await response.json();

  // Step 3: Log to audit trail
  const logEntry = await db.createDocumentLog({
    eventType,
    templateId: config.templateId,
    variables,
    pdfUrl: url,
    triggeredBy: eventData.userId || "system",
    metadata: eventData,
  });

  // Step 4: Deliver
  await deliverDocument(eventType, eventData, url);

  return { url, logId: logEntry.id };
}

Step 4: Delivery Layer

Different documents have different delivery needs:

// services/delivery.js
async function deliverDocument(eventType, eventData, pdfUrl) {
  const deliveryConfig = {
    PAYMENT_COMPLETED: [
      { method: "email", to: eventData.customerEmail },
      { method: "dashboard", userId: eventData.customerId },
    ],
    CONTRACT_SIGNED: [
      { method: "email", to: eventData.customerEmail },
      { method: "email", to: eventData.salesRepEmail },
      { method: "storage", bucket: "contracts" },
    ],
    MONTH_END_REPORT: [
      { method: "email", to: eventData.customerEmail },
      { method: "dashboard", userId: eventData.customerId },
    ],
  };

  const targets = deliveryConfig[eventType] || [];

  for (const target of targets) {
    switch (target.method) {
      case "email":
        await sendEmail(target.to, pdfUrl, eventType);
        break;
      case "dashboard":
        await saveToDashboard(target.userId, pdfUrl, eventType);
        break;
      case "storage":
        await copyToStorage(target.bucket, pdfUrl);
        break;
    }
  }
}

Step 5: Wire It Up

Connect your application events to the pipeline:

// In your payment service
const { processDocumentEvent } = require("./services/document-pipeline");

async function handlePaymentSuccess(payment) {
  // ... your existing payment logic ...

  // Trigger document generation
  await processDocumentEvent("PAYMENT_COMPLETED", {
    customerId: payment.customerId,
    customerEmail: payment.customerEmail,
    paymentId: payment.id,
    userId: payment.customerId,
  });
}

Or, if you use an event bus:

// Event listener
eventBus.on("payment.completed", async (event) => {
  await processDocumentEvent("PAYMENT_COMPLETED", event.data);
});

eventBus.on("contract.signed", async (event) => {
  await processDocumentEvent("CONTRACT_SIGNED", event.data);
});

Adding a New Document Type

With the pipeline in place, adding a new document type takes three steps:

  1. Create the template in Transactional.dev using HTML and Tailwind
  2. Add the event config to DOCUMENT_EVENTS
  3. Write the mapper function

No new infrastructure. No new error handling. No new delivery logic. The pipeline handles all of that.

Implementation Notes

Make generation async: For user-facing flows (like clicking "Download"), generation can be synchronous. For background events (like monthly reports), use a job queue so generation does not block your main application.

Add retry logic: API calls can fail. Wrap your generation call in a retry with exponential backoff. Three retries with 1s/5s/15s delays handles most transient failures.

Monitor the pipeline: Track generation count, failure rate, and latency. Set up alerts when the failure rate spikes. A broken document pipeline often means a broken customer experience.

Common Mistakes

Skipping the mapper layer: Passing raw database objects directly to the template is tempting but fragile. When your schema changes, every template breaks. Mappers provide a stable interface.

Not logging generation events: Without logs, you cannot answer "did this customer receive their invoice?" Build logging into the pipeline from day one.

Coupling delivery to generation: Generating the PDF and emailing it should be separate steps. If email delivery fails, the PDF is still generated and logged. You can retry delivery without regenerating.

Building the pipeline too early: If your app generates one type of document, a pipeline is overkill. Build it when you have two or three document types and feel the pain of duplication.

Conclusion

A document automation pipeline is the difference between "we can add that document in an afternoon" and "that is a two-week project." The pattern is straightforward: events trigger template selection, mappers prepare the data, the API generates the PDF, and delivery sends it where it needs to go.

Transactional.dev handles the generation and rendering. You build the pipeline around it.

Get started with Transactional.dev and build a document pipeline that scales with your product.