HTML render engine
Second-generation Airpdf render path: instead of the JSON-tree react-pdf pipeline, your template is plain HTML + CSS rendered by Chromium headless (via Puppeteer) for pixel-perfect output. Trade-off: ~5-10× slower than react-pdf, ~10× more memory per render, N× higher quota cost (default 25×).
Use the HTML engine when you need:
- Existing HTML/CSS layouts you can't or won't translate to JSON-tree
- Complex typography, Flexbox/Grid, web fonts, CSS custom properties
- Brand-perfect rendering matching an existing print stylesheet
- Headers/footers via Chromium's native PDF margin templates
Stick with the react-pdf engine (default) for high-volume, latency- sensitive workloads and standard document layouts.
Enablement
The HTML engine is opt-in per plan:
plan.html_engine_enabled : boolean (default false)
plan.html_engine_render_multiplier : integer (default 1, typical 25)
plan.html_rate_limit_per_minute : integer (default 10)
Toggled by the platform super-admin on a per-plan basis
(/platform/plans). Workspaces subscribed to a plan with
html_engine_enabled = false get HTTP 402 Payment Required if they
attempt an HTML render.
Template configuration
Each template carries an engine field (react_pdf | html). For HTML
templates also set html_template — the full HTML body with optional
Mustache placeholders.
Set via:
- Editor UI: open template editor → header toggle "JSON" / "HTML" (gated by plan flag)
-
API:
PATCH /api/v1/templates/:slugwith{ "engine": "html", "html_template": "<!doctype html>..." } -
IEx:
Airpdf.Templates.update_template(template, %{engine: :html, html_template: "..."}, scope: scope)
Variable substitution
The HTML body supports {{ path.to.value }} Mustache-style placeholders
hydrated server-side by Phoenix before shipping the body to Chromium.
Chromium itself never sees raw tenant template logic; this neutralises a
broad class of SSRF/code-execution attacks against the browser process.
Supported syntax:
| Syntax | Effect |
|---|---|
{{ key }} |
Top-level key, HTML-escaped |
{{ obj.nested.path }} |
Nested map traversal |
{{ items.0.label }} |
List index access |
{{{ key }}} |
Raw HTML (no escape) — use only for trusted partials |
Missing paths render as empty string. All non-string values are coerced
to string (integer, float, boolean, nil → "").
XSS defense: double-stash {{x}} always escapes <>&"'. Use the
triple-stash form {{{x}}} only when the value is known-safe HTML
generated by your own server.
Example
Template:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Inter', system-ui; padding: 40px; }
h1 { color: #06231a; border-bottom: 2px solid #9bf0c8; }
.total { font-size: 24px; font-weight: 700; }
</style>
</head>
<body>
<h1>Invoice {{ invoice.number }}</h1>
<p>To: <strong>{{ customer.name }}</strong> (VAT {{ customer.vat }})</p>
<p>Date: {{ invoice.date }}</p>
<table>
<thead><tr><th>Item</th><th>Qty</th><th>Amount</th></tr></thead>
<tbody>
<tr><td>{{ items.0.label }}</td><td>{{ items.0.qty }}</td><td>€ {{ items.0.amount }}</td></tr>
<tr><td>{{ items.1.label }}</td><td>{{ items.1.qty }}</td><td>€ {{ items.1.amount }}</td></tr>
</tbody>
</table>
<p class="total">Total: {{ totals.amount }}</p>
</body>
</html>
Render call:
curl -X POST https://airpdf.app/api/v1/render \
-H "x-api-key: $AIRPDF_API_KEY" \
-H "content-type: application/json" \
-d '{
"template": "invoice-html",
"data": {
"invoice": {"number":"2026-0042","date":"2026-05-10"},
"customer": {"name":"Globex Srl","vat":"IT09876"},
"items": [
{"label":"Pro plan","qty":1,"amount":99},
{"label":"Premium support","qty":1,"amount":29}
],
}
}' --output invoice.pdf
PDF options
Pass under the options field of the render call:
| Field | Default | Notes |
|---|---|---|
format |
"A4" |
A3, A4, A5, Letter, Legal |
landscape |
false |
|
margin |
{top:"40px",right:"40px",bottom:"40px",left:"40px"} |
Px or CSS units |
printBackground |
true |
Renders CSS backgrounds |
displayHeaderFooter |
false |
|
headerTemplate |
"" |
HTML; supports <span class="pageNumber"> etc |
footerTemplate |
"" |
Same |
scale |
1 |
0.1 — 2 |
waitUntil |
"networkidle0" |
load, networkidle0, networkidle2 |
Example:
{
"template": "invoice-html",
"data": { ... },
"options": {
"format": "A4",
"margin": { "top": "60px", "bottom": "80px" },
"printBackground": true,
"displayHeaderFooter": true,
"headerTemplate": "<div style='font-size:10px;width:100%;text-align:center'>Confidential</div>",
"footerTemplate": "<div style='font-size:10px;width:100%;text-align:center'>Page <span class=pageNumber></span> of <span class=totalPages></span></div>"
}
}
Engine override per request
For testing or migration, force the engine independently of the template
default by adding "engine": "html" or "engine": "react_pdf" to the
request body:
{ "template": "invoice", "data": {...}, "engine": "html" }
The plan gate still applies — HTML requires html_engine_enabled = true.
Quota & rate limits
Quota multiplier
Each HTML render consumes plan.html_engine_render_multiplier units
against the monthly render_limit_monthly, instead of 1. With multiplier
25, an HTML-heavy month of 100 renders eats 2,500 quota units, same as
2,500 react_pdf renders.
Rate limit
Separate bucket from the standard plug-level rate limit:
plan.rate_limit_per_minute : applies to /api/v1/render (any engine)
plan.html_rate_limit_per_minute : additional bucket, only for engine=:html
A request to render HTML increments both buckets. If either exceeds
its threshold the request gets 429 too_many_requests with a
retry-after header.
Limits and caveats
-
Font loading: Chromium downloads
@font-faceURLs at render time. Slow networks → render timeout. Prefer data-URI inline or tenant-uploaded font assets cached in your CDN. -
JavaScript: Inline
<script>runs in the browser context (no network restrictions). Avoid for security — use server-side data substitution via Mustache instead. - External resources: Images and stylesheets referenced by URL are fetched by Chromium, no proxy. Don't rely on localhost or internal URLs.
-
Visual regression: Pin Chromium version (managed by us via the
Docker image); snapshot-based regression tests recommended on your
side before bumping
render_version. -
Memory bomb defense: per-render budget enforced by
MAX_HEAP_MBon the render service; render process gracefully exits and is recycled by Fly when budgets are exceeded.
Error codes
| HTTP |
error |
When |
|---|---|---|
400 |
validation_failed (field: engine) |
Unknown engine value |
400 |
validation_failed (field: html_template) |
engine=:html but template has no body |
402 |
payment_required (code: html_engine_not_enabled) |
Plan doesn't include HTML |
429 |
html_rate_limit_exceeded |
Exceeded plan.html_rate_limit_per_minute |
502 |
render_server_error |
Chromium crashed or returned non-2xx |
504 |
render_timeout |
Render exceeded service timeout (default 25s) |