Leaf Viewer for developers: documentation
How to show the files on your server with the viewer, and how to convert files through the API. Keys, usage and limits are in the Ontiqo console.
Two ways
The viewer runs in your visitors’ browsers. Your page fetches a file from your own server and hands it to the viewer, which draws it. The file never reaches us; each document opened is counted against your licence.
The conversion API takes a file from your server and returns it in a format that ordinary browsers and programs open. Use it to show a file without the viewer, or to keep a copy in an everyday format. Each conversion is charged; a failed one isn’t.
The viewer: install
Download the viewer (version 2.2.1) and put its leaf-viewer folder on your website, for example so that https://example.com/leaf-viewer/embed.html is served. The page that shows documents must be on the same website as the folder. The folder’s README.md repeats what follows.
Show a file
<div id="viewer" style="height: 80vh"></div>
<script type="module">
import { mountLeaf } from '/leaf-viewer/embed-host.js';
const leaf = mountLeaf(document.getElementById('viewer'), {
base: '/leaf-viewer/',
licence: 'lv_your_key',
});
const file = await fetch('/files/report.docx');
leaf.open({ bytes: await file.arrayBuffer(), name: 'report.docx' });
</script>
The file can come from anywhere your page can fetch it: a static folder, your application’s download route, or storage it signs a link for. The name matters, because its extension is the first hint at what the file is (the bytes decide in the end). Call leaf.open() again to show the next file without reloading.
The licence key goes in the page, like a maps key: it works only on the websites set for it and proved in the console. If one gets out anyway, replace it there.
Options
| Option | What it does |
|---|---|
base | Where the leaf-viewer folder is, as a path. |
licence | Your licence key, lv_…. |
theme | 'day' or 'night'. Left out, it follows the visitor’s system. |
chrome | { rail: true, status: true } shows Leaf’s contents list and its status bar (zoom, width, pages). |
compact | Tighter margins, for a narrow column. |
copy | false shows documents without letting them be copied or printed. |
keys | Keys handed to your page instead of the viewer, for example ['ArrowLeft', 'ArrowRight']. |
onDocument, onError, onLink, onKey | Called with what happened: { name, kind, label }, a message, a link’s address, a key. |
mountLeaf returns an object with open({ bytes, name }), setTheme(theme), setOptions(options), refit() (after its container changes size) and destroy(). The bytes are moved into the viewer, not copied: pass a copy if your page keeps using them.
Prove your website
In the console, set the websites your licence is for. It gives you a short code: put it in a file at https://your-website/.well-known/leaf-viewer.txt and press Check again. A website covers its own address and everything under it (example.com covers docs.example.com). Until a website is proved, the viewer there shows a short message in place of each document and nothing is counted.
Security headers
The viewer uses WebAssembly, workers and pictures made in the page. Serve its folder with:
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data:; frame-ancestors 'self'; base-uri 'none'
and .wasm files as application/wasm. On the page that shows documents, allow the frame and the licence check: frame-src 'self' and connect-src https://account.ontiqo.com.
Views and limits
Before each document is shown, the viewer asks https://account.ontiqo.com/v1/viewer/view with your key; the browser adds your page’s address. Nothing else is sent: never the file, its name or anything in it. Past your plan’s views for the month, extra views you bought are used, then pay as you go if you turned it on, up to your monthly limit. With none left, your visitors see a short message in place of the document, and we email you before that happens. If the check can’t be reached within three seconds, the document is shown. On localhost and .test addresses nothing is counted, so you can build without a key; there, without an active licence key, every document carries a red On localhost and .test addresses nothing is counted, so you can build without a key.ldquo;Development copyOn localhost and .test addresses nothing is counted, so you can build without a key.rdquo; band.
The API: keys
Make keys in the console. A key is shown once, so copy it to your server; we keep only a hash. Send it with every request:
Authorization: Bearer cv_your_key
A key converts files and reads your settings. Tick Can change settings when you make it if your server should also change limits, keys and websites. Keep keys on your server: a key in a web page is anyone’s. All requests go to https://convert.leafviewer.app.
Convert a file
POST /v1/api/convert?name=report.wpd&to=docx
Authorization: Bearer cv_your_key
<the file's bytes>
A PDF can take up to two minutes for a long document; everything else takes seconds. The answer is the converted file, with Content-Disposition naming it and X-Leaf-Notes holding a URL-encoded JSON list of anything worth knowing (a font that was replaced, a part that couldn’t be carried over). Files up to 100 MB. Prepaid conversions are used first, then pay per use if you turned it on. A conversion that fails isn’t charged, and sending the same request again after a network failure doesn’t charge twice.
What a file can become
POST /v1/api/inspect?name=report.wpd
Authorization: Bearer cv_your_key
<the file's bytes>
Free. Returns what the file is and what it can be converted to, best first:
{ "kind": "word", "label": "WordPerfect document",
"targets": [ { "to": "docx", "label": "Word document" }, { "to": "txt", "label": "Plain text" }, { "to": "md", "label": "Markdown" } ] }
Formats
| The file is | It can become |
|---|---|
| A word-processor document (WordPerfect, ClarisWorks, Pages, old Word, Works, Hangul and more) | pdf, docx, txt, md |
| Another document (e-books, web pages, old help files, slides and more), or a spreadsheet | pdf, txt, md |
| A database or an archive | txt, md |
| A picture (camera raw, Photoshop, HEIC, old and scientific formats) | png, jpg, bmp |
| A drawing (CAD, metafiles, PICT, Visio and more) | svg, pdf |
| A font | ttf |
| Sound Leaf decodes, a score or a tracker tune | wav |
To show a file in a browser without the viewer, convert documents to pdf, pictures to png or jpg, and drawings to svg. A PDF is made the way Leaf prints: a word processor’s pages at their own paper size, slides a slide to a sheet, anything else on A4. inspect lists exactly what a given file can become.
Errors
Errors are JSON, { "error": "…" }, with a sentence you can show to a person.
| Status | Meaning |
|---|---|
| 400 | Something is missing from the request: no file, no to=, a setting of the wrong kind. |
| 401 | No key, or the key was deleted. |
| 402 | No conversions left, or this month’s pay-per-use limit is reached. |
| 403 | The key can’t change settings. |
| 413 | The file is over 100 MB. |
| 422 | The file can’t be read, or can’t become that format (the answer lists targets). Not charged. |
| 429 | More than 60 files a minute from one account. Wait a moment and send it again. |
| 503 | A moment’s trouble on our side. Try again. |
Examples
curl
curl -H "Authorization: Bearer $LEAF_KEY" --data-binary @report.wpd -o report.pdf "https://convert.leafviewer.app/v1/api/convert?name=report.wpd&to=pdf"
Node.js
import { readFile, writeFile } from 'node:fs/promises';
const name = 'drawing.dwg';
const r = await fetch(`https://convert.leafviewer.app/v1/api/convert?name=${encodeURIComponent(name)}&to=svg`, {
method: 'POST',
headers: { authorization: `Bearer ${process.env.LEAF_KEY}` },
body: await readFile(name),
});
if (!r.ok) throw new Error((await r.json()).error);
await writeFile('drawing.svg', Buffer.from(await r.arrayBuffer()));
Python
import os, requests
name = "scan.cr2"
with open(name, "rb") as f:
r = requests.post("https://convert.leafviewer.app/v1/api/convert",
params={"name": name, "to": "jpg"}, data=f,
headers={"Authorization": f"Bearer {os.environ['LEAF_KEY']}"})
if not r.ok:
raise RuntimeError(r.json()["error"])
open("scan.jpg", "wb").write(r.content)
PHP
$name = 'letter.cwk';
$ch = curl_init('https://convert.leafviewer.app/v1/api/convert?' . http_build_query(['name' => $name, 'to' => 'docx']));
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => file_get_contents($name), CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('LEAF_KEY'), 'Content-Type: application/octet-stream']]);
$out = curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) throw new Exception(json_decode($out)->error);
file_put_contents('letter.docx', $out);
Settings and usage
Everything the console shows, your server can read with any key, and change with a key that can change settings. Send JSON; amounts are US dollars.
| Request | What it does |
|---|---|
GET /v1/api/account | Everything below in one answer. |
GET /v1/api/usage?month=2026-11 | Conversions in a month (prepaid and pay per use, by key) and documents viewed by each licence. Without month, this month. |
GET /v1/api/settings | Conversions: pay per use, the monthly limit, and this month so far. |
PATCH /v1/api/settings | { "payPerUse": true, "monthlyLimit": 40 }. The limit is in steps of 5, from 5 to 10,000. |
GET /v1/api/keys | Your keys: label, the last four characters, whether each can change settings, when last used. |
POST /v1/api/keys | { "label": "Nightly import", "canChangeSettings": false }. The answer holds the new key, shown this once. |
DELETE /v1/api/keys/<id> | Stops a key at once. A key can’t delete itself. |
GET /v1/api/viewer/licences | Your viewer licences: plan, key, websites and whether each is proved, views this month, the proof code. |
PATCH /v1/api/viewer/licences/<key> | { "websites": ["example.com", "docs.example.org"] }, up to your plan’s number of websites. |
POST /v1/api/viewer/licences/<key>/check | Looks for the proof files again. |
POST /v1/api/viewer/licences/<key>/replace | A new licence key. The old one stops at once. |
GET /v1/api/viewer/settings | Views past your plan: extra views left, pay as you go, the monthly limit, this month so far. |
PATCH /v1/api/viewer/settings | { "payAsYouGo": true, "monthlyLimit": 100 }. |
curl -X PATCH -H "Authorization: Bearer $LEAF_KEY" -H "Content-Type: application/json" -d '{"monthlyLimit": 40}' https://convert.leafviewer.app/v1/api/settings
{ "payPerUse": true, "payPerUseReady": true, "monthlyLimit": 40, "pricePerConversion": 0.05,
"thisMonth": { "month": "2026-11", "conversions": 12, "charged": 0.6 } }
Buying, and turning on pay per use or pay as you go for the first time, need a card, so they’re done in the console. Until then an answer says "payPerUseReady": false and carries a note.