Documentation
Document blocks
Invoices, quotes and receipts are not prose — they are named regions of labelled data. Document blocks let a Markdown document declare those regions, and the invoice theme prints them.
```meta
---
| Invoice no. | Issued | Due |
| --- | --- | --- |
| INV-2026-014 | 2026-07-29 | 2026-08-13 |
```Blocks work in every theme — they inherit the theme's palette and typography — but the invoice theme is the one built around them.
Syntax
Every block body uses the same shape as a chart fence: optional settings, a separator, then one or more table sections.
key: value ← optional settings
another: value
--- ← separator (omit if there are no settings)
| A | B | ← a table section
| --- | --- |
| 1 | 2 |
=== ← optional: start another table section
| C | D |Alignment rows are optional inside blocks and always discarded. A block that can't render anything falls back to a visible code block — a malformed block never silently disappears from the page.
masthead
Logo on the left, document identity on the right.
```masthead
badge: ORIGINAL
title_label: Invoice no.
title: INV-2026-014
logo_width: 200
brand: Acme Ltd
---
| Issued: | 2026-07-29 |
```| Setting | Meaning |
|---|---|
title / title_label | The document number, with a muted label before it. |
badge | Small outlined tag above the title (ORIGINAL, COPY, DRAFT). |
logo_width | Logo width in px, 40–320. Default 180. |
brand | Text shown on the left when no logo resolves — always set it as a fallback. |
Table rows render as right-aligned label/value lines under the title.
parties
Who is billing whom — one column per table column.
```parties
to_caption: Bill to
---
| From | To |
| --- | --- |
| Acme Ltd | Globex SA |
| 12 Harbour Road | 4 Rue Centrale |
| Lisbon, Portugal | Lyon, France |
| VAT: PT514517654 | |
```- The first cell of a column is the party name, printed bold.
- Later cells are address lines.
- A cell shaped
Label: value(colon then a space) prints the label muted and the value plain — which is whyWeb: https://acme.comstays intact.
The header row is structural and never printed. Captions come from from_caption / to_caption (or col3_caption…), so you don't have to invent visible column titles.
meta
The labelled strip. Header row supplies the labels; each following row prints as one strip.
```meta
---
| VAT no. | Reference | Due | Total |
| --- | --- | --- | --- |
| PT508790980 | PO-4471 | 2026-08-13 | €1 279,20 |
```Line items
Line items are an ordinary Markdown table, not a block — so they keep alignment, inline emphasis and links, and break across pages with a repeating header.
| Item | Unit | Qty | Net | VAT | Total |
| --- | ---: | ---: | ---: | ---: | ---: |
| **DOM-01** - Domain renewal | €40,00 | 1 | €40,00 | 23% | €49,20 |totals
The last section is the right-hand ladder; every earlier section is an aside on the left. In an aside, a first row with a single cell becomes a heading instead of a table header.
```totals
title: Summary
total_label: Total due:
total: €1 279,20
---
| Tax | Base | Amount |
| --- | --- | --- |
| VAT 23% | €1 040,00 | €239,20 |
===
| Net: | €1 040,00 |
| VAT: | €239,20 |
```Computed totals
Getting an agent to do arithmetic is a bad idea on a financial document. Set compute: true and Inkrun sums the line items instead.
```totals
compute: true
net_column: Net
rate_column: VAT
currency: EUR
locale: pt-PT
total_label: Total due:
total: {gross}
---
| Net: | {net} |
| VAT: | {tax} |
| Lines: | {count} |
```| Setting | Meaning |
|---|---|
net_column | Header of the money column to sum. Matches case-insensitively and by prefix — Total finds Total s/ IVA. |
rate_column | Header of a percentage column (23,00%). Tax = net × rate, per line. |
tax_column | Header of a tax amount column. Takes precedence over rate_column. |
currency / locale | Formatting. Both fall back to the document's frontmatter. |
| Token | Value |
|---|---|
{net} | Sum of net_column. |
{tax} | Sum of tax (from tax_column, else rate_column, else 0). |
{gross} | {net} + {tax}. |
{count} | Number of summed line items. |
- The first Markdown table in the document is the line-item table. A blunt rule you can hold in your head, rather than a heuristic that changes your totals when you add an unrelated table.
- Amounts parse in either convention —
1,279.20,1.279,20and1 279,20 €all read as the same number. - Without
compute: true,{net}is just the text{net}. Tokens are inert unless you opt in. - Rounding happens once, at the end, so long invoices don't drift.
payment
```payment
title: Bank transfer
note: Please quote the invoice number.
---
| IBAN: | PT50 0000 0000 0000 0000 0000 1 |
| SWIFT/BIC: | XXXXXXXX |
```Logos
A logo is named by frontmatter, as a URL:
---
logo: https://acme.com/logo.png
---Inkrun fetches it once and embeds it in the PDF. The rules:
- PNG, JPEG or WebP, verified by magic bytes — the file extension and the server's content type are not trusted. SVG is refused (it can carry script).
- 300 KB maximum, public hosts only, redirects re-validated at every hop.
- Fail-open: an unreachable, oversized or non-image URL costs the document its logo, not the render — which is why
masthead'sbrandsetting is worth setting as a fallback. - Fetched logos are cached, so a run of invoices fetches a brand once.
Footer notes
Business documents carry a standing footer line. Two frontmatter keys build it — the note prints left, the page counter right, on every page.
---
footer_note: Processed by entity YZ
footer_page_label: Page
---Calling it from the API
There is no special endpoint and no special request field. Document blocks live in the Markdown, so an invoice is an ordinary POST /api/v1/render with theme: "invoice". If you already render with Inkrun, you can render invoices today by changing the theme and the Markdown you send.
curl "https://inkrun.dev/api/v1/render" \
-H "Authorization: Bearer $INKRUN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-2026-014" \
-d '{
"theme": "invoice",
"title": "INV-2026-014",
"markdown": "---\nlogo: https://acme.com/logo.png\ncurrency: EUR\nlocale: en-IE\n---\n\n```masthead\ntitle_label: Invoice no.\ntitle: INV-2026-014\nbrand: Acme Ltd\n---\n| Issued: | 2026-07-29 |\n```\n\n| Item | Net | VAT |\n| --- | ---: | ---: |\n| Domain renewal | 40.00 | 23% |\n| Website rebuild | 750.00 | 23% |\n\n```totals\ncompute: true\nnet_column: Net\nrate_column: VAT\ntotal_label: Total due:\ntotal: {gross}\n---\n| Subtotal: | {net} |\n| VAT: | {tax} |\n```\n"
}'{
"id": "rnd_9f2a…",
"status": "rendered",
"size": 31804,
"url": "https://…s3…/rnd_9f2a.pdf?X-Amz-Signature=…",
"stored": true
}Add Accept: application/pdf to get the bytes directly instead.
Building the Markdown in code
Escaping fences inside a JSON string by hand gets unpleasant fast — build the document as a string and let your JSON encoder handle it. Note what you don't send: no totals. You interpolate the line items and let compute: true derive the rest server-side, so the PDF can't disagree with the table above it.
const markdown = `---
logo: https://acme.com/logo.png
currency: EUR
locale: en-IE
footer_note: Acme Ltd · VAT PT514517654
---
\`\`\`masthead
title_label: Invoice no.
title: ${invoiceNo}
brand: Acme Ltd
---
| Issued: | ${issuedOn} |
\`\`\`
| Item | Net | VAT |
| --- | ---: | ---: |
${items.map((i) => `| ${i.desc} | ${i.net.toFixed(2)} | ${i.vat} |`).join("\n")}
\`\`\`totals
compute: true
net_column: Net
rate_column: VAT
total_label: Total due:
total: {gross}
---
| Subtotal: | {net} |
| VAT: | {tax} |
\`\`\`
`;
const res = await fetch("https://inkrun.dev/api/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INKRUN_API_KEY}`,
"Content-Type": "application/json",
// Safe to retry: a repeat returns the original render, uncharged.
"Idempotency-Key": `invoice-${invoiceNo}`,
},
body: JSON.stringify({ theme: "invoice", title: invoiceNo, markdown }),
});
const { url } = await res.json();Invoices are exactly the case idempotency exists for — a retried request after a network blip must not produce a second document or a second charge.
Saving the setup as a template
If every invoice uses the same accent, fonts and page format, save a template once and send its slug instead of theme. An explicit theme on a render still overrides the template's.
curl "https://inkrun.dev/api/v1/templates" \
-H "Authorization: Bearer $INKRUN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme Invoice", "themeName": "invoice", "format": "A4", "styles": { "accent": "#1F6FEB" } }'From an MCP agent
Over MCP there's no HTTP to write — the agent calls create_pdf with the same two arguments. The tool description tells the model the block vocabulary and, importantly, tells it not to compute totals itself. So “invoice Globex for the three items we discussed” produces a document whose arithmetic Inkrun owns.
create_pdf({ theme: "invoice", markdown: "---\nlogo: …" })extract_and_generate takes the same theme, so a supplier's PDF invoice can be re-rendered on your own letterhead in one call.
Errors worth handling
| Situation | What happens |
|---|---|
| Logo URL unreachable, oversized, or not a raster image | The render still succeeds — the document just has no logo. Set brand: in masthead as a fallback. |
| A block is malformed | It renders as a visible code block, so the PDF shows the source instead of dropping the region. |
compute: true but net_column matches no header | Tokens print as literal text ({net}), visible in the output — check the column name against your table header. |
theme: "invoice" misspelled | 400 with theme_not_found. |