PaperPony

Recipe

Invoice template

A complete Handlebars invoice template: line items, per-rate tax, totals, payment terms, and a table head that repeats on every page.

Worth knowing

  • The totals block is wrapped in break-inside: avoid so an invoice never splits between the last line and the amount due. A reader who sees only one of those halves has to go looking for the other.
  • Amounts arrive as major units, because formatCurrency formats them: 4308.00, not 430800. Keep integers in your own database and convert on the way out.
  • thead { display: table-header-group } is what makes page two of a long invoice legible. Without it the columns are unlabelled.

The template

Store it once with POST /v1/templates. It is compiled when you store it, so a mistake surfaces now rather than on the first render.

invoice.hbs
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    *, *::before, *::after { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: 'Noto Sans', sans-serif;
      font-size: 10pt;
      line-height: 1.5;
      color: #111827;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
    table { border-collapse: collapse; width: 100%; }
    thead { display: table-header-group; }
    tr { break-inside: avoid; }
    .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
    .band { background: {{brand_color}}; color: #fff; padding: 9mm 15mm; }
    .band h1 { margin: 0; font-size: 24pt; letter-spacing: -0.01em; }
    .band .number { margin-top: 2mm; opacity: 0.85; }
    .sheet { padding: 0 15mm; }
    .parties { display: flex; gap: 10mm; margin-top: 10mm; }
    .party { flex: 1; }
    .party h2 {
      margin: 0 0 2mm;
      font-size: 7.5pt;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      color: {{brand_color}};
    }
    .party .name { font-weight: 700; font-size: 11pt; }
    .party .address { color: #4b5563; font-size: 9pt; white-space: pre-line; }
    .items { margin-top: 11mm; }
    .items th {
      padding: 2.5mm 3mm;
      background: #f3f4f6;
      font-size: 8pt;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      text-align: left;
    }
    .items td { padding: 3mm; border-bottom: 1px solid #f0f1f3; }
    .totals { break-inside: avoid; margin-top: 7mm; margin-left: auto; width: 80mm; }
    .totals th { padding: 1.8mm 4mm 1.8mm 0; font-weight: 400; color: #6b7280; text-align: left; }
    .totals .due th, .totals .due td {
      padding-top: 3mm;
      border-top: 1.5px solid #111827;
      font-size: 12pt;
      font-weight: 700;
    }
    .closing { break-inside: avoid; margin-top: 12mm; color: #4b5563; font-size: 9pt; }
    .closing h2 {
      font-size: 7.5pt;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      color: {{brand_color}};
      margin: 0 0 1.5mm;
    }
  </style>
</head>
<body>
  <header class="band">
    <h1>{{title}}</h1>
    <div class="number">{{number}}</div>
  </header>

  <div class="sheet">
    <section class="parties">
      <div class="party">
        <h2>From</h2>
        <div class="name">{{seller.name}}</div>
        <div class="address">{{seller.address}}</div>
        {{#if seller.tax_id}}<div class="address">VAT {{seller.tax_id}}</div>{{/if}}
      </div>
      <div class="party">
        <h2>Bill to</h2>
        <div class="name">{{buyer.name}}</div>
        <div class="address">{{buyer.address}}</div>
      </div>
      <div class="party">
        <h2>Details</h2>
        <div class="address">Issued {{formatDate issued_on locale=locale}}</div>
        {{#if due_on}}<div class="address">Due {{formatDate due_on locale=locale}}</div>{{/if}}
      </div>
    </section>

    <table class="items">
      <thead>
        <tr>
          <th>Description</th>
          <th class="num">Qty</th>
          <th class="num">Unit price</th>
          <th class="num">Amount</th>
        </tr>
      </thead>
      <tbody>
        {{#each items}}
          <tr>
            <td>{{this.description}}</td>
            <td class="num">{{formatNumber this.quantity maximumFractionDigits=3}}</td>
            <td class="num">{{formatCurrency this.unit_price currency=../currency locale=../locale}}</td>
            <td class="num">{{formatCurrency this.amount currency=../currency locale=../locale}}</td>
          </tr>
        {{/each}}
      </tbody>
    </table>

    <table class="totals">
      <tr>
        <th>Subtotal</th>
        <td class="num">{{formatCurrency subtotal currency=currency locale=locale}}</td>
      </tr>
      {{#if discount}}
        <tr>
          <th>Discount</th>
          <td class="num">-{{formatCurrency discount currency=currency locale=locale}}</td>
        </tr>
      {{/if}}
      {{#each tax_rows}}
        <tr>
          <th>{{this.label}}</th>
          <td class="num">{{formatCurrency this.amount currency=../currency locale=../locale}}</td>
        </tr>
      {{/each}}
      <tr class="due">
        <th>Total</th>
        <td class="num">{{formatCurrency total currency=currency locale=locale}}</td>
      </tr>
    </table>

    {{#if payment_terms}}
      <section class="closing">
        <h2>Payment terms</h2>
        <div>{{payment_terms}}</div>
      </section>
    {{/if}}
  </div>
</body>
</html>

Rendering it

Substitute the template id you got back, and send this payload.

curl https://api.paperpony.dev/v1/pdf/render \
  -H "Authorization: Bearer $PAPERPONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "template_id": "tpl_YOUR_TEMPLATE_ID",
  "data": {
    "title": "Invoice",
    "number": "INV-0001",
    "issued_on": "2026-07-26",
    "due_on": "2026-08-25",
    "currency": "USD",
    "locale": "en-US",
    "brand_color": "#1f2937",
    "seller": {
      "name": "Northwind Studio",
      "address": "14 Bridgewater Street\nManchester M15 4RN\nUnited Kingdom",
      "tax_id": "GB123456789"
    },
    "buyer": {
      "name": "Fabrikam Ltd",
      "address": "9 Peveril Road\nSheffield S11 7AQ"
    },
    "items": [
      {
        "description": "Brand identity, discovery",
        "quantity": 12,
        "unit_price": 95,
        "amount": 1140
      },
      {
        "description": "Logo design",
        "quantity": 1,
        "unit_price": 1800,
        "amount": 1800
      },
      {
        "description": "Brand guidelines",
        "quantity": 1,
        "unit_price": 650,
        "amount": 650
      }
    ],
    "subtotal": 3590,
    "discount": 0,
    "tax_rows": [
      {
        "label": "VAT 20%",
        "amount": 718
      }
    ],
    "total": 4308,
    "payment_terms": "Payment due within 30 days. Bank transfer to Northwind Studio."
  },
  "options": {
    "format": "A4",
    "margin": {
      "top": "0",
      "right": "0",
      "bottom": "16mm",
      "left": "0"
    },
    "footer_html": "<div style=\"width:100%;padding:0 15mm;font-family:Helvetica,sans-serif;font-size:7pt;color:#9ca3af;text-align:right\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
  }
}'
import { PaperPony } from 'paperpony';

const pony = new PaperPony({ apiKey: process.env.PAPERPONY_API_KEY });

const job = await pony.pdf.render({
  "template_id": "tpl_YOUR_TEMPLATE_ID",
  "data": {
    "title": "Invoice",
    "number": "INV-0001",
    "issued_on": "2026-07-26",
    "due_on": "2026-08-25",
    "currency": "USD",
    "locale": "en-US",
    "brand_color": "#1f2937",
    "seller": {
      "name": "Northwind Studio",
      "address": "14 Bridgewater Street\nManchester M15 4RN\nUnited Kingdom",
      "tax_id": "GB123456789"
    },
    "buyer": {
      "name": "Fabrikam Ltd",
      "address": "9 Peveril Road\nSheffield S11 7AQ"
    },
    "items": [
      {
        "description": "Brand identity, discovery",
        "quantity": 12,
        "unit_price": 95,
        "amount": 1140
      },
      {
        "description": "Logo design",
        "quantity": 1,
        "unit_price": 1800,
        "amount": 1800
      },
      {
        "description": "Brand guidelines",
        "quantity": 1,
        "unit_price": 650,
        "amount": 650
      }
    ],
    "subtotal": 3590,
    "discount": 0,
    "tax_rows": [
      {
        "label": "VAT 20%",
        "amount": 718
      }
    ],
    "total": 4308,
    "payment_terms": "Payment due within 30 days. Bank transfer to Northwind Studio."
  },
  "options": {
    "format": "A4",
    "margin": {
      "top": "0",
      "right": "0",
      "bottom": "16mm",
      "left": "0"
    },
    "footer_html": "<div style=\"width:100%;padding:0 15mm;font-family:Helvetica,sans-serif;font-size:7pt;color:#9ca3af;text-align:right\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
  }
});

console.log(job.output_url);
import os
import requests

response = requests.post(
    "https://api.paperpony.dev/v1/pdf/render",
    headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
    json={
    "template_id": "tpl_YOUR_TEMPLATE_ID",
    "data": {
        "title": "Invoice",
        "number": "INV-0001",
        "issued_on": "2026-07-26",
        "due_on": "2026-08-25",
        "currency": "USD",
        "locale": "en-US",
        "brand_color": "#1f2937",
        "seller": {
            "name": "Northwind Studio",
            "address": "14 Bridgewater Street\nManchester M15 4RN\nUnited Kingdom",
            "tax_id": "GB123456789"
        },
        "buyer": {
            "name": "Fabrikam Ltd",
            "address": "9 Peveril Road\nSheffield S11 7AQ"
        },
        "items": [
            {
                "description": "Brand identity, discovery",
                "quantity": 12,
                "unit_price": 95,
                "amount": 1140
            },
            {
                "description": "Logo design",
                "quantity": 1,
                "unit_price": 1800,
                "amount": 1800
            },
            {
                "description": "Brand guidelines",
                "quantity": 1,
                "unit_price": 650,
                "amount": 650
            }
        ],
        "subtotal": 3590,
        "discount": 0,
        "tax_rows": [
            {
                "label": "VAT 20%",
                "amount": 718
            }
        ],
        "total": 4308,
        "payment_terms": "Payment due within 30 days. Bank transfer to Northwind Studio."
    },
    "options": {
        "format": "A4",
        "margin": {
            "top": "0",
            "right": "0",
            "bottom": "16mm",
            "left": "0"
        },
        "footer_html": "<div style=\"width:100%;padding:0 15mm;font-family:Helvetica,sans-serif;font-size:7pt;color:#9ca3af;text-align:right\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
    }
},
)
response.raise_for_status()
print(response.json()["output_url"])