Skip to content
CastPDF

Generate an invoice PDF from JSON

To generate an invoice PDF from JSON, send the seller, buyer, invoice details and line items as one object, together with the id of a saved invoice template. The template multiplies each line, adds tax and formats the money for your currency. Our invoice PDF API returns a print-ready file in one call.

  • Updated October 2026
  • 100 free PDFs a month

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:

invoice-data.json
{
  "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 }
  ]
}
What each group holds
KeyHoldsNotes
currency, localeAn ISO 4217 code and a BCP 47 tagDecide the symbol, separators and decimals of every amount.
companyYour businessUsually the same on every invoice. Store it once in your app config.
customerThe buyerName, contact person and a two-line address.
invoiceNumber, dates, terms, tax rateDates as ISO strings. The rate is a percentage, so 19 means 19%.
paymentBank detailsPrinted in the payment box next to the totals.
itemsThe linesEach 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:

Totals in the invoice starter
{% 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: 2 to 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.

create_invoice_pdf.py
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 as invoice-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.

FAQ

Common questions

Should the JSON include the invoice totals or should the template compute them?

Either works. Computing them in the template keeps the payload short and consistent. Sending them from your accounting system guarantees the PDF matches your books to the cent.

Why is my invoice total one cent off?

Usually because one side rounds each line and the other rounds only the total. Pick one rounding rule and apply it in your database, your emails and the template.

Can one invoice template handle several currencies?

Yes. Send the currency code and the locale as data and pass both to the money filter. The symbol, separators and decimals then change per invoice without editing the template.

Does CastPDF generate the invoice number for me?

No. Your system should assign it, because it knows the last number used and the sequence rules you must follow. Send the finished number in the data.

What Idempotency-Key should I use for an invoice?

Use something that names the document, such as the word invoice plus its number. A retry with that key returns the first PDF and is never counted twice.

How do I store the invoice PDF after it is generated?

Ask for a url response and keep the document id with your invoice record. Download the file into your own storage if you need it longer than your plan keeps it.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.