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}}insidefooterHtmlis substituted per render, so the invoice number can repeat on every page without being passed twice. pageNumberandtotalPagesare Chromium’s own classes, not ours. They are spans you style yourself. There is also a shorthand:"pageNumbers": trueprints 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:
| Template | What 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.
| Call | Server time | Result | Credits |
|---|---|---|---|
| Invoice from stored template, 3 rows | 187 ms | 1 page, 47,819 B | 1 |
| Two-address probe, inline HTML | 57 ms | 1 page, 9,833 B | 1 |
| Strict mode, one misspelled field | — | HTTP 400 | 0 |
| Unknown option key | — | HTTP 400 | 0 |
| Saving and reading the template | 126 ms | HTTP 200 | 0 |
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