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
| Method | Path | Body |
|---|---|---|
| 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /models, /models/{model}/fields | Odoo models; fields with dotted paths (partner_id.name, order_line.price_subtotal) |
| GET | /models/{model}/records, /models/{model}/sample | Real records (needs the Odoo connector); the record previews use |
| GET, POST | /templates | List; 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}/duplicate | Copy |
| POST | /templates/validate | {ok, errors, warnings}, each issue with a JSON path such as slots[2].row_template[1].content |
| POST | /templates/{id}/values, /values | What every {{ }} prints for one record (saved or unsaved layout) |
| POST | /templates/{id}/render, /render | Preview as pdf, html or png (page 1) |
| GET, POST | /starters, /templates/from-starter | Quotation, 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}/translations | Every static string of the template (header and footer parts included) with its translation per language; set many languages in one call |
| GET, POST | /email-templates | HTML 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/preview | Subject and HTML for one record, in a language (saved or unsaved email) |
| GET, PATCH, PUT | /email-templates/{id}/translations | The 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):