Docs menu

API referenceTemplates

Templates

Make templates from Markdown, HTML or an editor document, change and delete them, and render them with JSON or CSV data.

A template is a document with fields: places where a value from your data goes. Make one in the template editor, or send Markdown, HTML or an editor document to the API. Then send only the data: the response is the PDF. Every route below is also an MCP tool.

In a path, {id} is the template id or name: and the name, as in /v1/templates/name:Invoice. A template of another account answers 404.

List#

GET/v1/templates

The account's templates, the most recently saved first. Each entry has its id, name, revision and fields: the data paths, the CSV table sources and the JSON list sources the template reads. limit is how many templates the plan keeps.

curl https://api.legivel.com/v1/templates \
  -H "Authorization: Bearer $LEGIVEL_API_KEY"

Create#

POST/v1/templates

Saves a template. Send a name, unique in the account, and exactly one source:

NameTypeDescription
namerequiredstring1 to 120 characters.
markdownstringMarkdown to import. Headings, paragraphs, lists, quotes, code and tables become blocks; {{customer.name}} becomes a field.
htmlstringHTML to import, as for markdown. Inline color, weight, size and alignment stay; scripts, styles and forms are left out.
bodyobjectAn editor document, as GET returns it. A picture box takes a PNG or JPEG as a data URL; the save stores it with the template and names it asset:<id>. See Template format.
curl https://api.legivel.com/v1/templates \
  -H "Authorization: Bearer $LEGIVEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Offer",
        "markdown": "# Offer {{offer.number}}\n\nDear {{customer.name}},\n\nThank you for your request." }'

The answer is 201 with the new template. left lists what an import left out, with counts, such as ["2 images"]: images in Markdown and HTML are not imported. Add one in the editor, or as an image field.

{ "id": "8f0c2c3e-5b1d-4d7e-9a51-0d2b6c1f7a40",
  "name": "Offer",
  "revision": 1,
  "updated_at": "2026-10-14T15:02:11.000Z",
  "fields": { "paths": ["customer.name", "offer.number"], "csv": [], "arrays": [] },
  "thumbnail": false,
  "left": [] }

Creating costs no document. A name in use answers 409 template_name_taken; a full plan answers 403 template_limit_reached. Free keeps 3 templates, Starter 25, Pro 100 and Scale 1,000. Creating, updating and deleting need a verified email address, and an account makes at most 60 such changes a minute (429 rate_limited past that). A template is stored encrypted, its name included; the data you render it with is never stored.

Read#

GET/v1/templates/{id}

The template as it is now: body is the editor document, fields what it reads and revision the number to send back with an update.

curl https://api.legivel.com/v1/templates/name:Offer \
  -H "Authorization: Bearer $LEGIVEL_API_KEY"

Update#

PUT/v1/templates/{id}

Replaces the template. Send base_revision, the revision you read: when someone saved the template since, the answer is 409 template_version_conflict and nothing is saved. What you leave out stays as it was: send one of body, markdown or html to replace the document, name to rename it. The template keeps only its current document. To keep a copy first, read the template and create a new one from its body.

curl -X PUT https://api.legivel.com/v1/templates/name:Offer \
  -H "Authorization: Bearer $LEGIVEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "base_revision": 1,
        "name": "Offer 2026",
        "html": "<h1>Offer {{offer.number}}</h1><p>Dear {{customer.name}},</p>" }'

Delete#

DELETE/v1/templates/{id}

Answers 204. The template and its pictures are deleted at once and for good. A render that names it answers 404, and the name is free again. This cannot be undone.

curl -X DELETE https://api.legivel.com/v1/templates/name:Offer%202026 \
  -H "Authorization: Bearer $LEGIVEL_API_KEY"

Render#

POST/v1/templates/{id}/render

Fills the template with the data and answers 200 with Content-Type: application/pdf. The page count is in x-legivel-pages. A render counts one document toward the period, as a Markdown render does, and the plan's page and time limits apply.

A layout that looks broken is still rendered. Text that does not fit its box or table cell, a box drawn over text and a box past the page edge set x-legivel-warnings: layout_problem, and x-legivel-layout-problems lists up to ten of them as JSON, each with its path, page and message.

Send Idempotency-Key to retry safely: the same key and the same data answer with the first PDF again, marked x-legivel-replay: true, at no charge. The same key with other data is refused.

curl https://api.legivel.com/v1/templates/$TEMPLATE_ID/render \
  -H "Authorization: Bearer $LEGIVEL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-2026-0147" \
  -d '{ "data": { "invoice": { "number": "2026-0147" } },
        "csv": { "items": "item,qty\nPaper,2\n" } }' \
  -o invoice.pdf
  • application/json: data holds the field values; csv maps table sources to CSV text.
  • text/csv: the body is the CSV for the template's first CSV table. This works only for a template that reads nothing else; send JSON or multipart when it also has fields.
  • multipart/form-data: a data part with JSON and one csv:<source> part per table.
curl https://api.legivel.com/v1/templates/$TEMPLATE_ID/render \
  -H "Authorization: Bearer $LEGIVEL_API_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @items.csv \
  -o items.pdf
curl https://api.legivel.com/v1/templates/$TEMPLATE_ID/render \
  -H "Authorization: Bearer $LEGIVEL_API_KEY" \
  -F 'data={"invoice":{"number":"2026-0147"}}' \
  -F 'csv:items=@items.csv' \
  -o invoice.pdf

Data#

fields.paths names what the template reads, the values its formulas read included. Each kind of value fills one kind of element:

NameTypeDescription
text, number, datestring | numberA field in the text: {{customer.name}} reads {"customer": {"name": "…"}}. Numbers, money and dates follow the field's format.
listarray of objectsA table bound to the list, one body row per object. fields.arrays names it; each column reads one key, a formula on the record, or the fields in its cell.
CSVtextA table bound to csv:<name>. The first row names the columns; comma, semicolon or tab.
imagestringAn image box with a field: the image itself, as a data URL. See Images.
{ "data": {
    "customer": { "name": "Nordic Print Supplies AB", "since": "2024-05-01" },
    "company": { "logo": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…" },
    "lines": [
      { "item": "Toner cartridge", "qty": 2, "price": 9.5 },
      { "item": "Paper A4, 500 sheets", "qty": 8, "price": 22.5 }
    ] } }

Every field the template reads needs a value; an empty string is a value. A value is text in the PDF: it never becomes a link or a style.

Images

An image field takes the image itself: a PNG or JPEG file as a base64 data URL, data:image/png;base64,… or data:image/jpeg;base64,…, of at most 4 MB. The bytes must be the type the URL names. A link (https://…) is refused: legivel never fetches a file your data names. The image is scaled into its box.

# A PNG or JPEG file as a data URL, ready for the data:
echo "data:image/png;base64,$(base64 < logo.png | tr -d '\n')"

Refusals#

Data that does not fit the template answers 422 template_data_invalid, with one entry in issues per problem. Nothing is charged.

{ "code": "template_data_invalid",
  "error": "The data does not fill the template. See issues: each names the path and what is wrong.",
  "issues": [
    { "path": "csv:items", "message": "no CSV named \"items\"" },
    { "path": "company.logo", "message": "an image field takes the image as a data URL (data:image/png;base64,…); links are not fetched" } ] }

A source that is not an editor document answers 400 template_invalid with the first problem, such as blocks[3].spans is not a list; so does a document that names a font the editor does not offer, and Markdown nested more than 32 levels deep. A template over 2 MB answers 413. An unknown id answers 404 not_found. Every other refusal is the same as for POST /v1/render. See Errors and quota.