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://api.airpdf.io/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.

Cookie settings

Choose what you consent to. Technical cookies are always on, because the site doesn't work without them. Details in the Cookie Policy.

TechnicalRemember your cookie choice and protect the site from abuse. Always on.Always on