HTML to PDF without running Chromium or Gotenberg yourself

One call takes HTML or Markdown and returns a file. Page size, orientation, reusable templates, and the CSS that behaves differently on paper.

Running your own HTML-to-PDF renderer means a headless browser in your deployment, its memory profile, its crashes under concurrency and a base image that doubles in size. Infrai’s POST /v1/pdf/generate takes html or markdown directly, plus a page_size and orientation, and returns the document — no Chromium, no font packages, no container to keep alive.

The call is trivial. The interesting part is the CSS, because paper is not a screen.

Generate from HTML

curl -sS -X POST "https://api.infrai.cc/v1/pdf/generate" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Invoice INV-4821</h1><p>Northwind Ltd — due 2026-10-15</p>",
    "page_size": "A4",
    "orientation": "portrait",
    "store": true
  }'
{
  "ok": true,
  "data": {
    "pdf_id": "pdf_2fVc8nRqLmT4xBzY",
    "url": "https://files.infrai.cc/pdf/2fVc8nRqLmT4xBzY.pdf",
    "size_bytes": 24576,
    "page_count": 1,
    "sha256": "9f2c41bd7a9e8c0b5d3e1f7a2b8d4c6e0a9f3b1d5c7e2a4f6b8d0c2e4a6f8b0d",
    "created_at": "2026-09-21T03:45:00Z",
    "retention_days": 7,
    "source": "generate"
  }
}

page_size accepts A4, A3, A5, Letter, Legal and Tabloid; orientation is portrait or landscape. page_count in the response is the fact to check in a test — a one-page invoice that comes back as three pages is a CSS problem you want to catch before a customer does.

Markdown works too: pass markdown instead of html for anything where you’d rather not hand-write layout.

Reusable templates

Sending the same HTML with different values on every call is wasteful and drifts. POST /v1/pdf/template/create stores the markup once:

curl -sS -X POST "https://api.infrai.cc/v1/pdf/template/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice-v3",
    "html": "<h1>Invoice {{number}}</h1><p>{{customer}} — due {{due_date}}</p><p class=\"total\">{{total}}</p>",
    "vars_schema": {"number": "string", "customer": "string", "due_date": "string", "total": "string"}
  }'

Then render with template_id and template_vars:

curl -sS -X POST "https://api.infrai.cc/v1/pdf/generate" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "tpl_9wQ1zV6pLkS3dHyB",
    "template_vars": {"number": "INV-4821", "customer": "Northwind Ltd",
                      "due_date": "2026-10-15", "total": "$1,284.00"},
    "page_size": "A4",
    "store": true
  }'

vars_schema is the bit worth filling in. It documents what the template expects, which is the difference between a template someone else can use in a year and one they have to reverse-engineer from the markup.

The CSS that behaves differently on paper

This is where most of the surprises live, and none of them are API behaviour.

Viewport units are meaningless — 100vh has no definition on a page, so a layout built on them collapses. Use millimetres or centimetres, which are exactly what print CSS is for. Backgrounds and colour need print-color-adjust: exact or a renderer may drop them to save ink. And anything you don’t want split across a page boundary needs break-inside: avoid, which is the single most useful line in a print stylesheet.

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const PRINT_CSS = `
  @page { size: A4; margin: 18mm 16mm; }
  body { font: 11pt/1.45 -apple-system, "Helvetica Neue", Arial, sans-serif; color: #111; }
  /* Keep a table row or a card whole rather than letting it straddle a page. */
  tr, .card { break-inside: avoid; }
  /* Repeat table headers on every page — the default is to print them once. */
  thead { display: table-header-group; }
  .total { font-size: 16pt; font-weight: 600; }
  /* Renderers may drop backgrounds to save ink unless told otherwise. */
  * { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
`;

export async function renderInvoice(invoice) {
  const html = `<!doctype html><html><head><meta charset="utf-8">
    <style>${PRINT_CSS}</style></head><body>
    <h1>Invoice ${invoice.number}</h1>
    <p>${invoice.customer}</p>
    <table><thead><tr><th>Item</th><th>Amount</th></tr></thead><tbody>
      ${invoice.lines.map((l) => `<tr><td>${l.label}</td><td>${l.amount}</td></tr>`).join("")}
    </tbody></table>
    <p class="total">${invoice.total}</p>
    </body></html>`;

  const res = await fetch(`${API}/v1/pdf/generate`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ html, page_size: "A4", orientation: "portrait", store: true }),
  });
  const body = await res.json();
  if (!body.ok) throw new Error(body.error?.code ?? "generate_failed");
  // page_count is your regression test: assert it in CI against a fixture.
  return { pdfId: body.data.pdf_id, url: body.data.url, pages: body.data.page_count };
}

display: table-header-group on thead is the one people discover after shipping a twelve-page invoice whose column headings appear only on page one.

Then compress it

A generated document with embedded fonts and images is often larger than it needs to be for email:

curl -sS -X POST "https://api.infrai.cc/v1/pdf/compress" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"pdf": "pdf_2fVc8nRqLmT4xBzY", "mode": "balanced", "store": true}'

Note that the input is the pdf_id from the previous call — chained operations don’t need the bytes to travel back through your process. mode is fast, balanced or quality.

ChainCalls
Generate, sendpdf.generate → email.send
Generate, compress, archivepdf.generate → pdf.compress → storage.object.put
Generate, watermark a draftpdf.generate → pdf.watermark
Generate from template per customerpdf.template.create once → pdf.generate many

Limitations

There’s no JavaScript execution in the render, so a page whose content is assembled client-side isn’t a good fit — send server-rendered HTML. Custom fonts need to be reachable by the renderer rather than installed locally, and complex print features like running headers with page numbers depend on @page support rather than on anything this API exposes.

Self-hosted Gotenberg or a direct Puppeteer setup gives you the full browser, including JavaScript and any font you install, which matters if your documents are genuinely browser-dependent. PDFShift and PDFMonkey are the hosted specialists with richer template editors if document generation is the product.

What one credential buys is the chain: the template, the render, the compress, the archive with PUT /v1/storage/object/put/{bucket}/{key}, and the POST /v1/email/send that delivers it — one key, one invoice, one GET /v1/account/usage. Generation bills per call at a rate live in GET /v1/discovery/pdf.generate (verified 2026-09-21), and platform rates drift downward as vendor contracts improve.

References

Browse more pdf developer guides