Airpdf docs

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/:slug with { "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-face URLs 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_MB on 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)

Spotted a typo or stale claim? Open an issue or ping us in the workspace — docs are versioned with the product.