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.

markdown
```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.

The blocks carry no wording.Every visible label comes from your document, never from Inkrun, so the same block set prints a Portuguese factura, a German Rechnung and an English quote.

Syntax

Every block body uses the same shape as a chart fence: optional settings, a separator, then one or more table sections.

text
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.

markdown
```masthead
badge: ORIGINAL
title_label: Invoice no.
title: INV-2026-014
logo_width: 200
brand: Acme Ltd
---
| Issued: | 2026-07-29 |
```
SettingMeaning
title / title_labelThe document number, with a muted label before it.
badgeSmall outlined tag above the title (ORIGINAL, COPY, DRAFT).
logo_widthLogo width in px, 40–320. Default 180.
brandText 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.

markdown
```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 why Web: https://acme.com stays 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.

markdown
```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.

markdown
| 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.

markdown
```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.

markdown
```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} |
```
SettingMeaning
net_columnHeader of the money column to sum. Matches case-insensitively and by prefix — Total finds Total s/ IVA.
rate_columnHeader of a percentage column (23,00%). Tax = net × rate, per line.
tax_columnHeader of a tax amount column. Takes precedence over rate_column.
currency / localeFormatting. Both fall back to the document's frontmatter.
TokenValue
{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,20 and 1 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

markdown
```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:

markdown
---
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's brand setting is worth setting as a fallback.
  • Fetched logos are cached, so a run of invoices fetches a brand once.

Business documents carry a standing footer line. Two frontmatter keys build it — the note prints left, the page counter right, on every page.

markdown
---
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.

bash
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"
  }'
json
{
  "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.

javascript
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.

bash
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.

text
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

SituationWhat happens
Logo URL unreachable, oversized, or not a raster imageThe render still succeeds — the document just has no logo. Set brand: in masthead as a fallback.
A block is malformedIt renders as a visible code block, so the PDF shows the source instead of dropping the region.
compute: true but net_column matches no headerTokens print as literal text ({net}), visible in the output — check the column name against your table header.
theme: "invoice" misspelled400 with theme_not_found.