rendoc
← Back to blog

Build a PDF Invoice System with a REST API

6 min readrendoc team
tutorialinvoicesapi

Generating invoices is one of the most common PDF use cases in SaaS applications. Every billing system needs them. Every customer expects them. And yet, most teams spend far more time on invoice PDF generation than the feature deserves.

In this tutorial, you will build a complete invoice generation system using rendoc and a simple REST API. By the end, you will have an endpoint that accepts invoice data as JSON and returns a professionally formatted PDF.

What You Will Build

  • A REST endpoint that accepts invoice data as JSON.
  • A rendoc template that defines the invoice layout.
  • Automatic PDF generation and delivery in under 2 seconds.
  • Support for line items, tax calculations, and custom branding.

Prerequisites

  • Node.js 20 or later.
  • A rendoc account (the free tier includes 100 documents per month).
  • Basic familiarity with Express.js or any Node HTTP framework.

Step 1: Set Up the Project

Create a new directory and initialize a Node.js project with TypeScript:

mkdir invoice-api && cd invoice-api
npm init -y
npm install express rendoc
npm install -D typescript @types/express @types/node tsx

Create a minimal tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist"
  }
}

Step 2: Create the Invoice Template

Log into the rendoc dashboard and create a new template called invoice. The template uses standard HTML and CSS. Here is a simplified version of what the template markup looks like:

<div class="invoice">
  <header class="invoice-header">
    <div>
      <h1>INVOICE</h1>
      <p class="invoice-number">{{number}}</p>
    </div>
    <div class="company-info">
      <p class="company-name">{{company.name}}</p>
      <p>{{company.address}}</p>
      <p>{{company.email}}</p>
    </div>
  </header>

  <section class="billing">
    <div>
      <h3>Bill To</h3>
      <p class="client-name">{{client.name}}</p>
      <p>{{client.address}}</p>
      <p>{{client.email}}</p>
    </div>
    <div class="dates">
      <p><strong>Date:</strong> {{date}}</p>
      <p><strong>Due:</strong> {{dueDate}}</p>
    </div>
  </section>

  <table class="line-items">
    <thead>
      <tr>
        <th>Description</th>
        <th>Qty</th>
        <th>Price</th>
        <th>Total</th>
      </tr>
    </thead>
    <tbody>
      {{#each items}}
      <tr>
        <td>{{description}}</td>
        <td>{{quantity}}</td>
        <td>${{price}}</td>
        <td>${{total}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>

  <div class="totals">
    <p>Subtotal: ${{subtotal}}</p>
    <p>Tax ({{taxRate}}%): ${{taxAmount}}</p>
    <p class="grand-total">Total: ${{grandTotal}}</p>
  </div>
</div>

rendoc templates support Handlebars syntax for dynamic content. The visual editor lets you preview the output with sample data before connecting it to your API.

Step 3: Build the API Endpoint

Create src/server.ts with a single POST endpoint that receives invoice data, calls the rendoc API, and returns the PDF:

import express from "express";
import { Rendoc } from "rendoc";

const app = express();
app.use(express.json());

const rendoc = new Rendoc({
  apiKey: process.env.RENDOC_API_KEY!,
});

interface LineItem {
  description: string;
  quantity: number;
  price: number;
}

interface InvoiceRequest {
  number: string;
  date: string;
  dueDate: string;
  company: {
    name: string;
    address: string;
    email: string;
  };
  client: {
    name: string;
    address: string;
    email: string;
  };
  items: LineItem[];
  taxRate: number;
}

app.post("/api/invoices/pdf", async (req, res) => {
  const data: InvoiceRequest = req.body;

  // Calculate totals
  const items = data.items.map((item) => ({
    ...item,
    total: (item.quantity * item.price).toFixed(2),
  }));

  const subtotal = data.items.reduce(
    (sum, item) => sum + item.quantity * item.price,
    0
  );
  const taxAmount = subtotal * (data.taxRate / 100);
  const grandTotal = subtotal + taxAmount;

  const pdf = await rendoc.render({
    template: "invoice",
    data: {
      ...data,
      items,
      subtotal: subtotal.toFixed(2),
      taxAmount: taxAmount.toFixed(2),
      grandTotal: grandTotal.toFixed(2),
    },
  });

  res.setHeader("Content-Type", "application/pdf");
  res.setHeader(
    "Content-Disposition",
    `attachment; filename="invoice-${data.number}.pdf"`
  );
  res.send(pdf);
});

const PORT = process.env.PORT ?? 3001;
app.listen(PORT, () => {
  console.log(`Invoice API running on port ${PORT}`);
});

Step 4: Test the Endpoint

Start the server and send a test request:

npx tsx src/server.ts
curl -X POST http://localhost:3001/api/invoices/pdf \
  -H "Content-Type: application/json" \
  -d '{
    "number": "INV-1042",
    "date": "2026-03-15",
    "dueDate": "2026-04-14",
    "company": {
      "name": "Acme Corp",
      "address": "123 Main St, San Francisco, CA",
      "email": "billing@acme.com"
    },
    "client": {
      "name": "Widget Inc",
      "address": "456 Oak Ave, Austin, TX",
      "email": "accounts@widget.co"
    },
    "items": [
      {
        "description": "Web Development - March",
        "quantity": 40,
        "price": 150
      },
      {
        "description": "Hosting & Infrastructure",
        "quantity": 1,
        "price": 200
      }
    ],
    "taxRate": 8.5
  }' --output invoice.pdf

Open invoice.pdf and you should see a clean, professionally formatted invoice with all the data populated correctly.

Step 5: Add Error Handling

Production systems need proper error handling. Wrap the render call and validate input:

app.post("/api/invoices/pdf", async (req, res) => {
  const data: InvoiceRequest = req.body;

  if (!data.number || !data.items?.length) {
    return res.status(400).json({
      error: "Missing required fields: number, items",
    });
  }

  try {
    const pdf = await rendoc.render({
      template: "invoice",
      data: { /* ... computed fields ... */ },
    });

    res.setHeader("Content-Type", "application/pdf");
    res.send(pdf);
  } catch (err) {
    console.error("PDF generation failed:", err);
    res.status(502).json({
      error: "Failed to generate PDF. Please try again.",
    });
  }
});

Step 6: Integrate with Your Billing System

In a real application, you would call this endpoint from your billing workflow. For example, when a Stripe webhook fires for a successful payment:

// Inside your Stripe webhook handler
async function handlePaymentSuccess(invoice: StripeInvoice) {
  const pdfResponse = await fetch(
    "http://localhost:3001/api/invoices/pdf",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        number: invoice.number,
        date: new Date().toISOString().split("T")[0],
        dueDate: invoice.due_date,
        company: getCompanyDetails(),
        client: getClientDetails(invoice.customer),
        items: invoice.lines.data.map((line) => ({
          description: line.description,
          quantity: line.quantity ?? 1,
          price: line.amount / 100,
        })),
        taxRate: 0,
      }),
    }
  );

  const pdfBuffer = await pdfResponse.arrayBuffer();

  // Email the invoice to the customer
  await sendInvoiceEmail(invoice.customer_email, pdfBuffer);

  // Store in your file system or S3
  await uploadToStorage(`invoices/${invoice.number}.pdf`, pdfBuffer);
}

Production Considerations

Caching

If the same invoice is requested multiple times, cache the generated PDF in your storage layer (S3, R2, or local disk) and serve it from cache on subsequent requests. There is no reason to regenerate a PDF that has not changed.

Async Generation

For high-volume systems, generate invoices asynchronously. Push invoice data to a queue (SQS, BullMQ, or similar), process PDFs in background workers, and notify users when the document is ready. This prevents PDF generation from blocking your API response times.

Template Versioning

rendoc supports template versions, so you can update your invoice design without affecting PDFs already generated. Pin your API calls to a specific template version in production and update deliberately.

Summary

You now have a working invoice PDF system: a REST endpoint that accepts JSON, a template that defines the layout, and a rendering pipeline that produces consistent output without managing any PDF infrastructure.

The full workflow is: define your template once in rendoc, send data from your application, receive a finished PDF. As your invoice design evolves, update the template in the dashboard. No code changes, no redeployments.

Generate PDFs without the headaches

rendoc turns your templates into pixel-perfect PDFs with a single API call. Free tier included.