What you will build
By the end of this guide, your billing code will send one JSON object per invoice and get back a finished PDF. The layout comes from the invoice template, one of the starters in the dashboard. You keep the numbers and the customer records in your own system. CastPDF only turns them into a document.
If templates and Liquid are new to you, the general PDF from JSON guide explains the basics with a packing slip. This page focuses on what makes invoices special: money, tax, rounding and the cost of sending one twice.
The invoice JSON shape
The invoice starter expects six groups of values. The names below are the ones its HTML reads, so a payload in this shape renders without any template changes. Here is a full example for a translation studio billing a client in euros:
{
"currency": "EUR",
"locale": "de-DE",
"company": {
"name": "Lumen Sprachdienste",
"initials": "LS",
"tagline": "Translation and localisation",
"street": "Bergmannstrasse 41",
"city": "10961 Berlin",
"email": "[email protected]",
"phone": "+49 30 5550 1187",
"website": "lumen.example",
"tax_id": "DE 312 456 789"
},
"customer": {
"name": "Nordkap Outdoor GmbH",
"contact": "Jana Hoffmann",
"street": "Am Sandtorkai 8",
"city": "20457 Hamburg"
},
"invoice": {
"number": "LS-2026-0318",
"issued": "2026-10-02",
"due": "2026-10-16",
"project": "Winter catalogue, EN to DE",
"po_number": "NK-4410",
"tax_rate": 19,
"terms": "Payable within 14 days without deduction.",
"note": "Vielen Dank für Ihren Auftrag."
},
"payment": {
"bank": "Spreebank Berlin",
"account_name": "Lumen Sprachdienste",
"account": "DE00 1234 5678 9012 3456 78",
"routing": "SPREDEB1XXX"
},
"items": [
{ "description": "Catalogue translation", "detail": "84 product pages, EN to DE", "quantity": 12400, "unit": "words", "unit_price": 0.14 },
{ "description": "Terminology glossary", "detail": "320 approved outdoor terms", "quantity": 1, "unit": "", "unit_price": 380 },
{ "description": "Proofreading", "detail": "Second linguist, full pass", "quantity": 6.5, "unit": "hrs", "unit_price": 58 }
]
}| Key | Holds | Notes |
|---|---|---|
currency, locale | An ISO 4217 code and a BCP 47 tag | Decide the symbol, separators and decimals of every amount. |
company | Your business | Usually the same on every invoice. Store it once in your app config. |
customer | The buyer | Name, contact person and a two-line address. |
invoice | Number, dates, terms, tax rate | Dates as ISO strings. The rate is a percentage, so 19 means 19%. |
payment | Bank details | Printed in the payment box next to the totals. |
items | The lines | Each line has quantity and unit_price as numbers, never as formatted strings. |
Notice what is missing: there is no subtotal, tax amount or total. The template works those out from the lines, so the JSON can never disagree with itself.
Line totals and tax with Liquid math
Liquid has arithmetic filters: times, plus, minus and divided_by. The invoice starter uses them to add up the lines before it prints anything. Here is that part of its HTML, trimmed to the essentials:
{% assign subtotal = 0 %}
{% for item in items %}
{% assign line = item.quantity | times: item.unit_price %}
{% assign subtotal = subtotal | plus: line %}
{% endfor %}
{% assign tax = subtotal | times: invoice.tax_rate | divided_by: 100 %}
{% assign total = subtotal | plus: tax %}
<p>Subtotal: {{ subtotal | money: currency, locale }}</p>
<p>Tax ({{ invoice.tax_rate | number: 3, locale }}%): {{ tax | money: currency, locale }}</p>
<p>Total due: {{ total | money: currency, locale }}</p>The loop runs once to build the subtotal. The table further down runs a second loop to print each line, repeating the same times step. Tax is the subtotal times the rate, divided by 100. With the example data, the three lines come to 1,736.00, 380.00 and 377.00 euros, so the subtotal is 2,493.00 and the tax at 19% is 473.67.
The money filter then prints each value as 2.493,00 € for the German locale, with a no-break space so the symbol never wraps onto its own line. The Liquid reference lists every argument.
Rounding: where cents go missing
Computers store decimals in binary, so a sum like 0.1 plus 0.2 is a hair off 0.3. The money filter rounds to the currency’s decimals when it prints, which hides those tiny errors. But rounding only at the end is a choice, and your accountant may expect a different one.
- Round per line. Some tax rules want each line rounded before the sum. Add
| round: 2to the line step, as in{% assign line = item.quantity | times: item.unit_price | round: 2 %}. - Round the tax once. Most invoices compute tax on the subtotal and round that single figure. This is what the starter does.
- Match your ledger. If your accounting system already holds the exact totals, send them as data and print them. The PDF then cannot differ from the books by even a cent.
Pick one rule and use it everywhere: in your database, your emails and the PDF. A mismatch of one cent between the email and the attachment causes more support tickets than any layout bug.
Zero-decimal currencies
The number of decimals follows the currency. Japanese yen prints with none, so 1,500.4 becomes ¥1,500. Send yen amounts as whole numbers to avoid surprises.
Currency and locale are separate choices
The currency says what the money is. The locale says how to write it for the reader. A Swiss client paying in euros might want fr-CH, while a Dutch client paying in pounds sterling wants nl-NL. Keeping both in the data, rather than in the HTML, lets one template serve every market.
Dates follow the same idea. The starter prints {{ invoice.issued | format_date: "medium" }}, which comes out in US English. For a German reader, pass the time zone and locale too: format_date: "medium", "Europe/Berlin", locale. Send plain ISO dates such as 2026-10-02, and let the template decide how they look.
Keep the numbering in your own system
CastPDF does not assign invoice numbers. That is deliberate. Many tax authorities expect numbers that are unique and in sequence, and only your database knows which number came last. Reserve the number in a transaction first, save the invoice record, and only then ask for the PDF.
That order matters when things fail. If the render fails, you still have a numbered invoice and can try again. If you rendered first and the database write failed, you would have a PDF with a number nobody recorded.
The full request
Save the starter as your own template in the dashboard, then copy its id. The request below sends the data from above and asks for a signed link instead of the raw file, which suits storing the PDF or attaching it to an email later. It is plain Python with the requests package; the API reference shows the same call in other languages.
import json
import os
import requests
with open("invoice-data.json", encoding="utf-8") as f:
data = json.load(f)
number = data["invoice"]["number"]
response = requests.post(
"https://api.castpdf.com/v1/pdf",
headers={
"Authorization": f"Bearer {os.environ['CASTPDF_API_KEY']}",
"Idempotency-Key": f"invoice-{number}",
},
json={
"template_id": os.environ["INVOICE_TEMPLATE_ID"],
"data": data,
"filename": f"{number}.pdf",
"response": "url",
"metadata": {"invoice_number": number},
},
timeout=60,
)
if response.status_code >= 400:
error = response.json()["error"]
raise RuntimeError(f"{error['code']}: {error['message']}")
document = response.json()
print(document["url"], document["pages"], document["expires_at"])The answer holds the document id, the page count and a url that works without a key until expires_at. Save the id with your invoice record. You can fetch a fresh link for it later from GET /v1/pdf/:id while the file is kept.
Retries without a second invoice
Networks drop requests. Without protection, a retry after a timeout could create a second document and count it twice. The Idempotency-Key header in the code above prevents that. Its value is built from the invoice number, so it names this exact invoice.
- A repeat with the same key and the same body returns the first document. Nothing renders again and nothing is counted again.
- A repeat that arrives while the first is still rendering gets
409 idempotency_conflict. Wait a moment and try again. - The same key with a changed body is refused with
422 idempotency_mismatch. If you corrected the data, the corrected invoice needs its own key, such asinvoice-LS-2026-0318-v2. - A key is remembered for at least 24 hours. Retries happen within minutes, so that is plenty.
Retry network errors, 503 and 429 with the same key. For 429, wait for the seconds in the Retry-After header first. Do not retry 422 errors blindly: they mean the data or template needs a fix.
Make it yours
The starter is a sound base, but your invoice probably needs your logo, your colours and maybe a different column set. The HTML invoice template guide explains each part of a print-ready invoice so you can change it safely. If your invoices start life as card payments, custom invoices from payment events shows how to map them.