Invoice PDF API

Store the layout once, then POST the numbers. This page is one real invoice — three line items, VAT, a page footer — built against the live API on 30 August 2026, including the three things that went wrong while building it.

What an invoice PDF really needs #

Almost every invoice is the same five parts, and only one of them is interesting:

  • A header block: number, dates, who is billing whom.
  • A table with a variable number of rows. This is the part that makes a plain string-replace template unusable, and it is the only genuinely hard part.
  • A totals block whose arithmetic you did before you called anything.
  • A payment-terms paragraph.
  • A footer that has to repeat on every page and know how many pages there are.

PDFMint does not do the arithmetic and does not decide your VAT treatment. It takes markup with {{markers}} in it, substitutes your JSON, and prints the result with Chromium. Your accounting logic stays where it belongs — in your system, where it can be tested.

Store the layout once #

A stored template is addressed by a name you choose, not by an opaque id, so it can be written into a workflow and reviewed in a diff. Repeat blocks use {{#items}}…{{/items}}.

curl -X PUT https://pdf.mintapis.com/v1/templates/invoice-a4 \
  -H "Authorization: Bearer $PDFMINT_KEY" -H "content-type: application/json" \
  -d '{
    "html": "<h1>Invoice {{invoice_number}}</h1> … <tbody>{{#items}}<tr><td>{{description}}</td><td>{{qty}}</td><td>{{amount}}</td></tr>{{/items}}</tbody> …",
    "options": {
      "format": "A4",
      "margin": "18mm",
      "footerHtml": "<div>{{invoice_number}} · page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
    }
  }'

Two details that are easy to miss and cost a round trip each:

  • The footer is filled from the same data as the body. {{invoice_number}} inside footerHtml is substituted per render, so the invoice number can repeat on every page without being passed twice.
  • pageNumber and totalPages are Chromium’s own classes, not ours. They are spans you style yourself. There is also a shorthand: "pageNumbers": true prints a centred Page n of m and needs no markup at all.

There is no displayHeaderFooter flag to set. A header or footer displays because you supplied one; sending the flag is rejected as an unknown field, with the accepted list in the error.

Fill it and print it #

POST https://pdf.mintapis.com/v1/pdf
{
  "template": "invoice-a4",
  "filename": "invoice-2026-0418.pdf",
  "data": {
    "invoice_number": "2026-0418",
    "buyer_name": "Achterberg Handel BV",
    "items": [
      {"description": "Pallet freight Rotterdam → Passau", "qty": "4", "unit_price": "185,00", "amount": "740,00"},
      {"description": "Customs clearance (EX-A)", "qty": "1", "unit_price": "65,00", "amount": "65,00"},
      {"description": "Waiting time, 45 min", "qty": "0,75", "unit_price": "48,00", "amount": "36,00"}
    ],
    "net_total": "841,00", "vat_rate": "19 %", "vat_amount": "159,79",
    "currency": "EUR", "grand_total": "1.000,79"
  }
}

That exact call, run against the live service from Germany on 30 August 2026, returned a one-page A4 PDF of 47,819 bytes in 187 ms server-side and 291 ms round trip. The text layer, read back with pdftotext, has all three rows, the totals, and the footer line 2026-0418 · page 1 of 1.

Values are HTML-escaped on substitution, so a buyer called M&S <Holdings> prints as written instead of breaking the table. Decimal commas, thousands dots and a in a description all survive unchanged — they are just text.

The field names nobody can retype #

Saving a template returns the list of markers it uses, so a client — the n8n node, a form, your own admin screen — can draw one input per marker instead of asking someone to remember them. Building this page found that the list was wrong for exactly the case that matters.

The scan ran the template against empty data. An absent repeat block correctly renders to nothing, so the scan never walked into it — and every column name inside the line-item table was missing from the list. Worse, the ready-to-paste example said:

"data": { "items": "value", "invoice_number": "value", … }

A string is precisely what a repeat block cannot take. Paste that and you get an invoice with a header, a totals row and no line items at all, with no error, because a section that is present but is not a list renders once and empty. The feature built to prevent retyping was handing out a shape that silently loses the invoice.

Fixed on 30 August 2026. The scan now walks a repeat block once against an empty row, purely to record what is inside it, and each field carries the block it belongs to:

"placeholders": [
  {"name": "items", "kind": "section"},
  {"name": "description", "kind": "scalar", "scope": "items"},
  {"name": "qty",         "kind": "scalar", "scope": "items"},
  {"name": "amount",      "kind": "scalar", "scope": "items"},
  {"name": "invoice_number", "kind": "scalar"}
],
"usage": { "data": { "invoice_number": "value", "items": [ {"description": "value", "qty": "value", "amount": "value"} ] } }

Two blocks may reuse a name — {{#items}}{{amount}}{{/items}} and {{#fees}}{{amount}}{{/fees}} are different columns — and they stay distinct. Rendering did not change: an absent block still prints nothing, and the walk is scan-only. GET /v1/templates/{name} now returns the same usage block as PUT, because GET is the call you make to draw a form.

A newline in your data is not a line break #

The buyer address went in as "Keizersgracht 214\n1016 DX Amsterdam\nNetherlands" and came out on the paper as one line. Nothing was broken: substitution puts text into HTML, and HTML collapses whitespace. Both behaviours in a single measured render:

TemplateWhat printed
<div>{{addr}}</div>Keizersgracht 214 1016 DX Amsterdam Netherlands
<div style="white-space:pre-line">{{addr}}</div>three lines, as sent

One CSS declaration, decided by you per field, which is right: an address wants the line breaks, a product description usually does not. The alternative — turning \n into <br> for you — would mean injecting markup into escaped values, and then a customer whose name legitimately contains a newline gets markup they did not ask for.

A misspelling that costs money #

Invoices are generated unattended. The failure that hurts is not a crash — it is a document that looks fine and is wrong. Two guards, both measured on the live service:

Strict placeholders. "strict": true refuses to print a marker that your data does not fill:

HTTP 400 unresolved_placeholders
"The template uses 1 placeholder that \"data\" does not provide: clietn_name."

Unknown fields are named, not ignored. Sending "fromat": "A5" to /v1/pdf returns HTTP 400 with Did you mean "format"? rather than quietly printing A4.

Building this page found a hole in that second guard, and it was in the worst possible place. PUT /v1/templates/… validated the values of the options it recognised but never checked the keys. Proven against the live service:

PUT  /v1/templates/typo-probe   {"options": {"fromat": "A5", "landscpae": true}}   → HTTP 200, stored
POST /v1/pdf                    {"fromat": "A5"}                                   → HTTP 400

The endpoint you call once caught the typo. The endpoint you set up once and then render from a thousand times did not — it accepted it, stored it, and silently printed the default page size on every invoice afterwards. A stored template’s options are now checked against the same list as a render, so the typo is refused where it is written instead of being obeyed forever.

What it cost and how long it took #

All of it on one free account created for this page on 30 August 2026.

CallServer timeResultCredits
Invoice from stored template, 3 rows187 ms1 page, 47,819 B1
Two-address probe, inline HTML57 ms1 page, 9,833 B1
Strict mode, one misspelled fieldHTTP 4000
Unknown option keyHTTP 4000
Saving and reading the template126 msHTTP 2000

Across the whole session — five successful renders and five rejected calls — the account reported credits_used: 5. A call that returns no document is not billed, and a render that fails after the credit is taken has it refunded. Storing, reading and listing templates are free. Every response carries X-PDFMint-Duration-Ms and X-PDFMint-Credits-Remaining, so you can log both without a second request.

When not to use us #

  • You need e-invoicing compliance — ZUGFeRD, Factur-X, XRechnung, Peppol, the Italian SdI. Those need structured XML embedded in, or instead of, the PDF, plus network delivery and archival. PDFMint prints a PDF. It does not embed a ZUGFeRD payload and does not sign anything. If a tax authority has to read your invoice, buy a product that names your regime.
  • Your invoices come out of your ERP already. SAP, Business Central, Odoo, Xero and Lexware all print PDFs. Adding an API to re-render what they already produce buys you nothing.
  • Non-developers must change the layout. There is no drag-and-drop editor and none is planned. If the finance team needs to move a logo without a deploy, a product with a designer is the honest answer.
  • You render a handful a month. A headless Chromium in your own container is free and this is not hard. Our HTML to PDF page says plainly when self-hosting is the right call.
  • You need to read invoices, not write them. Pulling the number, the due date and the line items out of an invoice that arrived in your inbox is the opposite job, and PDFMint does not do it. MailMint is ours and does: mail in, structured JSON out, with a confidence and the verbatim evidence span per field.
  • Legally required long-term archival in PDF/A. We do not emit PDF/A.

What we cannot claim #

No outside customer has paid for PDFMint yet, so there is no invoicing case study and no reference to call. The free tier is 10 documents a month, the smallest of the hosted options. One service, one region, no SLA, no SOC 2, no PDF/A, no digital signatures, no e-invoicing formats. The numbers on this page are single measurements from one machine in Germany on one day, not a benchmark; the status page is the running record. Two of the three problems described above were found while writing this page and fixed the same day — which is also a fair statement about how much production invoicing traffic this has seen.

Try it #

Take your ugliest real invoice — the one with the long product names, the credit note line and the address that needs three lines — and render that one. A template that survives your worst document will survive the rest.

Get an API key   Template reference   Live latency and success rate

Related #

HTML to PDF APIWhat the job really involves, and what six products charge.
Markdown to PDFA report written in Markdown, printed with page numbers.
Merge PDFsInvoice, terms and annex into one file, in one call.
HTML to PDF in n8nThree ways to do it in a workflow, and what each costs.