How to Generate Invoice PDFs with an API

Every SaaS that handles payments needs to generate invoices. And every team that builds invoice generation from scratch regrets it. Here's how to skip the pain and set up a reusable invoice template you can call from any backend.

Why invoice generation is harder than it looks

On the surface, generating an invoice PDF sounds simple. You have some data (customer name, line items, totals), you put it in a document, you export to PDF. Done, right?

In practice, here's what actually happens:

  • You start with a quick HTML string in your backend code. It works for the first version.
  • Then someone asks for a logo. Now you're embedding base64 images.
  • Line items vary in length. Your layout breaks when there are more than 8 items.
  • Page breaks land in the middle of a table row.
  • You set up Puppeteer or wkhtmltopdf to render the HTML. Now you're maintaining a headless browser in production.
  • Puppeteer needs Chromium. Your Docker image goes from 200MB to 1.2GB.
  • Memory spikes when you generate 50 invoices at once after a batch billing run.
  • Six months later, nobody wants to touch the invoice code.

The core issue: PDF rendering is an infrastructure problem disguised as a formatting task. You don't want to own a rendering engine just to produce a document.

The approach: template + variables + API call

The pattern that works is the same one you already use for transactional emails:

  1. Design a template once with HTML and CSS. Use placeholders for dynamic data.
  2. Send the data via an API call when you need a PDF.
  3. Get a PDF back. No browser, no rendering engine, no layout debugging.

With Transactional.dev, templates use HTML with Tailwind CSS for styling and Handlebars for dynamic content. You build the template in the dashboard, then call the API with your variables. The platform handles rendering, fonts, page breaks, and hosting.

Building an invoice template

Here's what a real invoice template looks like. This uses Tailwind classes for layout and Handlebars expressions for the dynamic parts:

<div class="max-w-2xl mx-auto p-8 font-sans text-gray-800">
  <!-- Header -->
  <div class="flex justify-between items-start mb-10">
    <div>
      <h1 class="text-2xl font-bold text-gray-900">Invoice</h1>
      <p class="text-sm text-gray-500 mt-1">{{invoice_number}}</p>
    </div>
    <div class="text-right text-sm text-gray-600">
      <p class="font-semibold text-gray-900">{{company_name}}</p>
      <p>{{company_address}}</p>
      <p>{{company_email}}</p>
    </div>
  </div>

  <!-- Customer & dates -->
  <div class="flex justify-between mb-8 text-sm">
    <div>
      <p class="text-gray-500 uppercase text-xs tracking-wide mb-1">Bill to</p>
      <p class="font-semibold text-gray-900">{{customer_name}}</p>
      <p class="text-gray-600">{{customer_email}}</p>
      {{#if customer_address}}
        <p class="text-gray-600">{{customer_address}}</p>
      {{/if}}
    </div>
    <div class="text-right">
      <p><span class="text-gray-500">Date:</span> {{invoice_date}}</p>
      <p><span class="text-gray-500">Due:</span> {{due_date}}</p>
    </div>
  </div>

  <!-- Line items -->
  <table class="w-full text-sm mb-8">
    <thead>
      <tr class="border-b-2 border-gray-200 text-left text-gray-500 uppercase text-xs tracking-wide">
        <th class="pb-3">Description</th>
        <th class="pb-3 text-right">Qty</th>
        <th class="pb-3 text-right">Unit price</th>
        <th class="pb-3 text-right">Amount</th>
      </tr>
    </thead>
    <tbody>
      {{#each items}}
        <tr class="border-b border-gray-100">
          <td class="py-3">{{this.description}}</td>
          <td class="py-3 text-right">{{this.quantity}}</td>
          <td class="py-3 text-right">{{this.unit_price}}</td>
          <td class="py-3 text-right font-medium">{{this.amount}}</td>
        </tr>
      {{/each}}
    </tbody>
  </table>

  <!-- Totals -->
  <div class="flex justify-end">
    <div class="w-64">
      <div class="flex justify-between py-2 text-sm">
        <span class="text-gray-500">Subtotal</span>
        <span>{{subtotal}}</span>
      </div>
      {{#if tax_amount}}
        <div class="flex justify-between py-2 text-sm">
          <span class="text-gray-500">Tax ({{tax_rate}})</span>
          <span>{{tax_amount}}</span>
        </div>
      {{/if}}
      <div class="flex justify-between py-3 text-base font-bold border-t-2 border-gray-900 mt-2">
        <span>Total</span>
        <span>{{total}}</span>
      </div>
    </div>
  </div>

  {{#if notes}}
    <div class="mt-10 pt-6 border-t border-gray-200 text-sm text-gray-500">
      <p class="font-medium text-gray-700 mb-1">Notes</p>
      <p>{{notes}}</p>
    </div>
  {{/if}}
</div>

A few things to notice:

  • Conditional blocks ({{#if tax_amount}}) let you handle optional fields without building multiple templates.
  • Loops ({{#each items}}) handle any number of line items. The rendering engine manages page breaks if the table gets long.
  • Tailwind classes mean you don't need a separate CSS file. Everything is inline and predictable.

You create this template once in the Transactional.dev dashboard and get a template ID back. That's the only thing your backend needs to reference.

Generating the PDF via API

Once your template exists, generating a PDF is a single POST request. Here's the curl version:

curl -X POST https://api.transactional.dev/v1/generate \
  -H "x-api-token: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "documentId": "tmpl_invoice_v1",
    "variables": {
      "invoice_number": "INV-2025-0042",
      "company_name": "Acme SaaS Inc.",
      "company_address": "123 Startup Lane, SF 94105",
      "company_email": "billing@acmesaas.com",
      "customer_name": "Jane Cooper",
      "customer_email": "jane@bigcorp.com",
      "customer_address": "456 Enterprise Ave, NY 10001",
      "invoice_date": "May 28, 2025",
      "due_date": "June 27, 2025",
      "items": [
        {
          "description": "Pro Plan - Monthly",
          "quantity": 1,
          "unit_price": "$99.00",
          "amount": "$99.00"
        },
        {
          "description": "Additional seats (x3)",
          "quantity": 3,
          "unit_price": "$15.00",
          "amount": "$45.00"
        }
      ],
      "subtotal": "$144.00",
      "tax_rate": "10%",
      "tax_amount": "$14.40",
      "total": "$158.40",
      "notes": "Payment due within 30 days. Thank you for your business."
    }
  }'

The API returns a hosted PDF URL. You can store it, email it to the customer, or redirect to it directly. No files to manage on your end.

In Node.js, the same call looks like this:

const response = await fetch("https://api.transactional.dev/v1/generate", {
  method: "POST",
  headers: {
    "x-api-token": `${process.env.TRANSACTIONAL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    documentId: "YOUR_DOCUMENT_UUID",
    variables: invoiceData,
  }),
});

const { url } = await response.json();
// url → "https://cdn.transactional.dev/docs/abc123.pdf"

That's it. No Puppeteer, no Chromium, no container tuning. Your backend sends JSON, gets a PDF URL back.

Hooking it up to Stripe webhooks

The most common use case: generate an invoice every time Stripe processes a payment. Here's how that looks with a webhook handler:

import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const event = stripe.webhooks.constructEvent(
      req.body,
      req.headers["stripe-signature"],
      process.env.STRIPE_WEBHOOK_SECRET
    );

    if (event.type === "invoice.payment_succeeded") {
      const stripeInvoice = event.data.object;

      const invoiceData = {
        invoice_number: stripeInvoice.number,
        company_name: "Acme SaaS Inc.",
        company_address: "123 Startup Lane, SF 94105",
        company_email: "billing@acmesaas.com",
        customer_name: stripeInvoice.customer_name,
        customer_email: stripeInvoice.customer_email,
        invoice_date: new Date(
          stripeInvoice.created * 1000
        ).toLocaleDateString("en-US", {
          year: "numeric",
          month: "long",
          day: "numeric",
        }),
        due_date: new Date(
          stripeInvoice.due_date * 1000
        ).toLocaleDateString("en-US", {
          year: "numeric",
          month: "long",
          day: "numeric",
        }),
        items: stripeInvoice.lines.data.map((line) => ({
          description: line.description,
          quantity: line.quantity || 1,
          unit_price: `$${(line.unit_amount / 100).toFixed(2)}`,
          amount: `$${(line.amount / 100).toFixed(2)}`,
        })),
        subtotal: `$${(stripeInvoice.subtotal / 100).toFixed(2)}`,
        tax_rate: stripeInvoice.tax
          ? `${(
              (stripeInvoice.tax / stripeInvoice.subtotal) *
              100
            ).toFixed(0)}%`
          : null,
        tax_amount: stripeInvoice.tax
          ? `$${(stripeInvoice.tax / 100).toFixed(2)}`
          : null,
        total: `$${(stripeInvoice.total / 100).toFixed(2)}`,
      };

      const pdfResponse = await fetch(
        "https://api.transactional.dev/v1/generate",
        {
          method: "POST",
          headers: {
            x-api-token: `${process.env.TRANSACTIONAL_API_KEY}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            documentId: "YOUR_DOCUMENT_UUID",
            variables: invoiceData,
          }),
        }
      );

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

      // Store the PDF URL alongside the payment record
      await db.invoices.create({
        stripeInvoiceId: stripeInvoice.id,
        pdfUrl: url,
        customerEmail: stripeInvoice.customer_email,
      });

      // Optionally email the invoice to the customer
      await sendInvoiceEmail(stripeInvoice.customer_email, url);
    }

    res.json({ received: true });
  }
);

The key point: your webhook handler stays focused on business logic (mapping Stripe data to template variables), not on PDF rendering mechanics.

Implementation notes

A few things worth considering when you set this up:

Format your data before sending. The template renders exactly what you pass. If you send raw cents instead of formatted dollar strings, that's what shows up on the invoice. Do your formatting in the webhook handler, not in the template.

Handle webhook retries. Stripe retries failed webhooks. If your PDF generation call is idempotent (same input produces the same output), retries are harmless. But make sure you don't email the same invoice twice. Use the Stripe invoice ID as a deduplication key.

Think about tax and locale. The template above handles optional tax with an {{#if}} block. If you operate in multiple countries, you might want separate templates per locale, or a single template with conditional sections for different tax formats (VAT, GST, sales tax).

Don't block the webhook response. Stripe expects a 2xx response within a few seconds. If PDF generation takes longer, acknowledge the webhook immediately and process the PDF asynchronously via a job queue. The API call is fast (typically under 2 seconds), but network hiccups happen.

Store the PDF URL, not the PDF file. Transactional.dev hosts the generated PDFs on a CDN with signed URLs. You don't need to download and store the file yourself unless you have specific compliance requirements that demand it.

Version your templates. When you update your invoice design, create a new template version instead of editing the existing one. This way, historical invoices still render correctly if you ever need to regenerate them.

Wrapping up

Invoice PDF generation doesn't need to be a project. The pattern is straightforward: design your template with HTML and Tailwind, map your payment data to template variables, and call the API. It works the same whether you're generating one invoice or a thousand.

If you want to avoid maintaining your own PDF rendering stack, Transactional.dev gives you a template-based PDF API that works like transactional email. Create a free account, build your first template, and hook it up to your Stripe webhooks in an afternoon.