PDF Generation API

Generate clean, paginated PDFs from Markdown, HTML or a URL with a simple REST call. Same engine as the free tools, authenticated with an API key.

1. Get an API key

Sign in and create a key in your cabinet → API tokens. Send it on every request:

Authorization: Bearer <YOUR_API_KEY>

2. Endpoints

MethodPathBody
POST/api/v1/markdown-to-pdf{ markdown, page_size?, title?, file_name? }
POST/api/v1/html-to-pdf{ html, page_size?, title?, file_name? }
POST/api/v1/url-to-pdf{ url, page_size?, file_name? }

All return application/pdf. page_size ∈ a4 | letter | a5 | legal (default a4). A branded footer with page numbers is added to every page.

url-to-pdf loads the page in headless Chrome: its JavaScript runs, web fonts and lazy images load, and it prints with its screen layout at desktop width, scaled to the paper, up to 30 pages. Pages behind a login, cookie walls that need a click and private or internal addresses are not captured.

3. Examples

curl

curl -X POST https://erp2pdf.com/api/v1/markdown-to-pdf \
  -H "Authorization: Bearer $ERP2PDF_KEY" \
  -H "Content-Type: application/json" \
  -d '{"markdown":"# Hello\n\nFrom **Markdown** to PDF.","page_size":"a4"}' \
  --output hello.pdf

Python

import requests

r = requests.post(
    "https://erp2pdf.com/api/v1/markdown-to-pdf",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"markdown": "# Hello\n\nFrom **Markdown** to PDF.", "page_size": "a4"},
)
r.raise_for_status()
open("hello.pdf", "wb").write(r.content)

JavaScript (Node / fetch)

const res = await fetch("https://erp2pdf.com/api/v1/markdown-to-pdf", {
  method: "POST",
  headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ markdown: "# Hello\n\nFrom **Markdown** to PDF.", page_size: "a4" }),
});
const buf = Buffer.from(await res.arrayBuffer());
require("fs").writeFileSync("hello.pdf", buf);

4. Rate limits

Authenticated requests are limited to 1 PDF per minute per account. Exceeding it returns 429 with a Retry-After header. Need higher throughput? Get in touch.

5. Template API (Pro plan)

Build and test Studio templates over the API — the way an AI agent works: find the Odoo model and its fields, create a template from a starter or from scratch, validate it, see what every expression prints for a real record, render a preview, write the email templates Odoo sends, and translate either kind into any language. Previews carry a PREVIEW watermark and cost no credits. Available on Pro, Enterprise and Agency; other plans get 402 with code: "upgrade_required".

Authentication

Issue a dedicated token in your cabinet → API tokens with Allow Template API (AI agents, MCP) ticked, and send it only in the header: Authorization: Bearer <token> (a token in the URL or the body is ignored). Never reuse the Odoo connector's token: it lives in Odoo's settings, has no Template API scope and gets 403 with code: "token_scope". A separate token also lets you revoke the agent without breaking your Odoo connector. Unknown or revoked tokens get 401; a revoked token stops working immediately.

Endpoints

All under /api/v1/studio. Errors are JSON {"detail": {"code", "message"}} with a stable code.

MethodPathPurpose
GET/models, /models/{model}/fieldsOdoo models; fields with dotted paths (partner_id.name, order_line.price_subtotal)
GET/models/{model}/records, /models/{model}/sampleReal records (needs the Odoo connector); the record previews use
GET, POST/templatesList; create {name, layout, mapping?}
GET, PUT, PATCH, DELETE/templates/{id}Read, replace, edit in place (upsert_slots, delete_slots, layout_set), delete
POST/templates/{id}/duplicateCopy
POST/templates/validate{ok, errors, warnings}, each issue with a JSON path such as slots[2].row_template[1].content
POST/templates/{id}/values, /valuesWhat every {{ }} prints for one record (saved or unsaved layout)
POST/templates/{id}/render, /renderPreview as pdf, html or png (page 1)
GET, POST/starters, /templates/from-starterQuotation, invoice, purchase order, product sheet, blank — with the shared company header/footer
GET, POST, PUT, DELETE/parts, /parts/{id}Shared header and footer parts (layout.master_refs)
GET, PATCH, PUT/templates/{id}/translationsEvery static string of the template (header and footer parts included) with its translation per language; set many languages in one call
GET, POST/email-templatesHTML email templates Odoo sends; list, create {name, bind_model, subject, body_html}
GET, PATCH, DELETE/email-templates/{id}Read, change, delete
POST/email-templates/{id}/preview, /email-templates/previewSubject and HTML for one record, in a language (saved or unsaved email)
GET, PATCH, PUT/email-templates/{id}/translationsThe email's subject and body text, per language

Translations

Odoo prints a template and sends an email in the customer's language: the exact Odoo code (de_CH), then its base language (de), then the source text. GET …/translations lists every string — labels, column titles, header and footer texts, an email's subject and text runs — with translations, a status per language and glossary suggestions. A string that prints values keeps them as placeholders: translate Quotation {{ name }} as Angebot {{ name }}; a translation must keep exactly the same expressions, in any order, or the call answers 400 with placeholder_mismatch and saves nothing.

curl -s -X PATCH "$API/templates/$TID/translations" -H "$H" -H "Content-Type: application/json" -d '{
  "translations": {"de": {"Total": "Summe", "Quotation {{ name }}": "Angebot {{ name }}"},
                   "fr_CH": {"Total": "Total"}},
  "fill_from_glossary": ["ru", "uk"]}' | jq .missing

Layout in one paragraph

A layout is {bind_model, slots[], master_refs?, translations?}. Coordinates are points on an A4 page (595 × 842, origin top-left). Slot kind: text (content, fontSize, fontWeight, color, align, fontFamily), image and qr (source), shape, line, arrow, ruler, and repeater — a table bound to a one2many (binding: "$.order_line") whose row_template cells read the line ({{ price_subtotal }}) at row-relative coordinates. On orders and invoices, blocks below the table follow its last row. Expressions: {{ partner_id.name }}, {{ money(amount_total, currency_id) }}, {{ date_order ? formatDate(date_order, "02.01.2006") : "" }}; no object literals.

Example: from a starter to a PNG

API=https://erp2pdf.com/api/v1/studio
H="Authorization: Bearer $ERP2PDF_API_KEY"

TID=$(curl -s -X POST "$API/templates/from-starter" -H "$H" -H "Content-Type: application/json" \
  -d '{"starter":"quotation","name":"Quotation (agent)"}' | jq .id)

curl -s -X POST "$API/templates/$TID/values" -H "$H" -H "Content-Type: application/json" -d '{}' \
  | jq '.slots[] | {path, value}'

curl -s -X POST "$API/templates/$TID/render" -H "$H" -H "Content-Type: application/json" \
  -d '{"output":"png"}' --output quotation.png

MCP server for AI agents

The erp2pdf-studio-mcp server (stdio, official MCP Python SDK) exposes these endpoints as tools: list_models, get_fields, create_from_starter, update_template, validate_layout, get_values, render_preview, get_translations, set_translations, create_email_template, preview_email and more. It is open source (MIT): github.com/c4pointer/erp2pdf-studio-mcp. Run it with uv, no install step needed; Claude Desktop / Cursor config:

{
  "mcpServers": {
    "erp2pdf-studio": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/c4pointer/erp2pdf-studio-mcp", "erp2pdf-studio-mcp"],
      "env": { "ERP2PDF_API_KEY": "<token for the agent>" }
    }
  }
}

Claude Code: claude mcp add erp2pdf-studio -e ERP2PDF_API_KEY=<token> -- uvx --from git+https://github.com/c4pointer/erp2pdf-studio-mcp erp2pdf-studio-mcp. Or pip install git+https://github.com/c4pointer/erp2pdf-studio-mcp and use the erp2pdf-studio-mcp command.

Limits

Per token: 240 reads/min; 60 writes/min and 1,000/hour; validate and values 60/min; renders 40/min and 600/hour. Layout JSON up to 512 KB, 400 top-level slots.

6. Interactive reference

Try the API live below (paste your key via Authorize):