Example

Invoice PDF example (full template + JSON + API)

A complete invoice walkthrough: full HTML + CSS template, variable placement, JSON payload, and production render request with HookPDF API.

If you want one practical reference article for your team, this is it.

In this tutorial, we will build an invoice PDF flow from zero with the exact HookPDF pattern:

  1. Create template with HTML + CSS.
  2. Add dynamic fields with Handlebars variables.
  3. Prepare matching JSON payload.
  4. Send render request to /v1/render.
  5. Read PDF status from /v1/reports/{job_id}.

No extra abstraction, no unsupported features.

Flow diagram (template -> JSON -> render)

TEXT
HTML + CSS invoice template
  +
Handlebars variables ({{company.name}}, {{#each items}}, ...)
  |
  v
JSON payload with matching keys
  |
  v
POST /v1/render
  |
  v
job_id + status: queued
  |
  v
GET /v1/reports/{job_id}
  |
  v
status: completed -> output_url -> PDF

Step 1 - Create a full invoice template

Start with a clean, print-safe HTML structure. Keep sections explicit so it stays readable over time.

HTML
<style>
  @page {
    size: A4;
    margin: 18mm;
  }

  body {
    font-family: Arial, sans-serif;
    color: #0f172a;
    font-size: 12px;
    line-height: 1.5;
  }

  .invoice {
    width: 100%;
  }

  .header {
    display: flex;
    justify-content: space-between;
    margin-bottom: 18px;
  }

  .brand h1 {
    margin: 0;
    font-size: 22px;
  }

  .meta p {
    margin: 0;
    text-align: right;
  }

  .section-title {
    font-size: 13px;
    margin: 14px 0 6px;
    font-weight: 700;
  }

  .invoice-table {
    width: 100%;
    border-collapse: collapse;
    margin-top: 10px;
  }

  .invoice-table th,
  .invoice-table td {
    border-bottom: 1px solid #cbd5e1;
    padding: 8px;
    text-align: left;
    vertical-align: top;
  }

  .invoice-table th.num,
  .invoice-table td.num {
    text-align: right;
    white-space: nowrap;
  }

  .totals {
    width: 320px;
    margin-left: auto;
    margin-top: 14px;
  }

  .totals-row {
    display: flex;
    justify-content: space-between;
    padding: 4px 0;
  }

  .totals-row.grand {
    border-top: 1px solid #94a3b8;
    margin-top: 6px;
    padding-top: 8px;
    font-weight: 700;
    font-size: 14px;
  }

  .footer-note {
    margin-top: 20px;
    color: #475569;
    font-size: 11px;
  }
</style>

<div class="invoice">
  <div class="header">
    <div class="brand">
      <h1>{{company.name}}</h1>
      <p>{{company.address}}</p>
      <p>{{company.email}}</p>
    </div>
    <div class="meta">
      <p><strong>Invoice:</strong> {{invoice.number}}</p>
      <p><strong>Issue date:</strong> {{invoice.issue_date}}</p>
      <p><strong>Due date:</strong> {{invoice.due_date}}</p>
    </div>
  </div>

  <div>
    <div class="section-title">Bill to</div>
    <p>{{customer.name}}</p>
    <p>{{customer.address}}</p>
    <p>{{customer.email}}</p>
  </div>

  <table class="invoice-table">
    <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>{{description}}</td>
        <td class="num">{{quantity}}</td>
        <td class="num">{{unit_price}}</td>
        <td class="num">{{amount}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>

  <div class="totals">
    <div class="totals-row">
      <span>Subtotal</span>
      <span>{{totals.subtotal}}</span>
    </div>
    <div class="totals-row">
      <span>Tax</span>
      <span>{{totals.tax}}</span>
    </div>
    <div class="totals-row grand">
      <span>Total</span>
      <span>{{totals.total}}</span>
    </div>
  </div>

  <p class="footer-note">{{notes}}</p>
</div>

Step 2 - Verify variable and JSON mapping

Before API calls, validate that every variable has a payload path.

Template variable JSON path
{{invoice.number}} invoice.number
{{customer.name}} customer.name
{{#each items}} items[]
{{totals.total}} totals.total
{{notes}} notes

This one-minute check saves most rendering mistakes.

Step 3 - Prepare JSON payload

JSON
{
  "company": {
    "name": "HookPDF Inc.",
    "address": "750 Howard Street, San Francisco",
    "email": "billing@hookpdf.com"
  },
  "invoice": {
    "number": "INV-2026-0118",
    "issue_date": "2026-03-19",
    "due_date": "2026-04-02"
  },
  "customer": {
    "name": "Northwind Labs",
    "address": "42 Market Street, San Francisco",
    "email": "finance@northwind.example"
  },
  "items": [
    {
      "description": "Growth plan - March",
      "quantity": 1,
      "unit_price": "$29.00",
      "amount": "$29.00"
    },
    {
      "description": "Data export add-on",
      "quantity": 3,
      "unit_price": "$12.00",
      "amount": "$36.00"
    }
  ],
  "totals": {
    "subtotal": "$65.00",
    "tax": "$6.50",
    "total": "$71.50"
  },
  "notes": "Thank you for your business."
}

Step 4 - Send API render request

BASH
curl -X POST "https://api.hookpdf.com/v1/render" \
  -H "Authorization: Bearer <api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "1c24f959-8e1a-4a14-acc4-cf57b4e60e99",
    "payload": {
      "company": {
        "name": "HookPDF Inc.",
        "address": "750 Howard Street, San Francisco",
        "email": "billing@hookpdf.com"
      },
      "invoice": {
        "number": "INV-2026-0118",
        "issue_date": "2026-03-19",
        "due_date": "2026-04-02"
      },
      "customer": {
        "name": "Northwind Labs",
        "address": "42 Market Street, San Francisco",
        "email": "finance@northwind.example"
      },
      "items": [
        {
          "description": "Growth plan - March",
          "quantity": 1,
          "unit_price": "$29.00",
          "amount": "$29.00"
        }
      ],
      "totals": {
        "subtotal": "$29.00",
        "tax": "$2.90",
        "total": "$31.90"
      },
      "notes": "Thank you for your business."
    }
  }'

Successful queue response looks like this:

JSON
{
  "job_id": "43e78393-7b7a-4f71-b9e1-9f4655d2d8d0",
  "status": "queued",
  "is_preview": false
}

Step 5 - Fetch job status and output URL

BASH
curl -X GET "https://api.hookpdf.com/v1/reports/43e78393-7b7a-4f71-b9e1-9f4655d2d8d0" \
  -H "Authorization: Bearer <api_key>"

When status becomes completed, the response includes output_url.

Quick debug checklist

  1. Check variable paths first (invoice.number vs invoice_number).
  2. Confirm items is an array when using #each.
  3. Keep currency/date values formatted before API request.
  4. Test once with a minimal payload, then increase complexity.

Use this template for free now

Start with this template in HookPDF and render your first PDF for free.

Use this template free

Related guides

Ready to move from prototype to production?

Create your account and generate your first API-driven PDF with HookPDF.

Get your API key