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:
- Create template with HTML + CSS.
- Add dynamic fields with Handlebars variables.
- Prepare matching JSON payload.
- Send render request to
/v1/render. - Read PDF status from
/v1/reports/{job_id}.
No extra abstraction, no unsupported features.
Flow diagram (template -> JSON -> render)
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.
<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
{
"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
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:
{
"job_id": "43e78393-7b7a-4f71-b9e1-9f4655d2d8d0",
"status": "queued",
"is_preview": false
}
Step 5 - Fetch job status and output URL
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
- Check variable paths first (
invoice.numbervsinvoice_number). - Confirm
itemsis an array when using#each. - Keep currency/date values formatted before API request.
- 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 freeRelated guides
Ready to move from prototype to production?
Create your account and generate your first API-driven PDF with HookPDF.
Get your API key