Blog/SDKs

HTML to PDF in Node.js

SDKs··5 min read

An html to pdf nodejs api is a typed HTTP client that sends HTML or a public URL to a hosted Chromium print and returns PDF bytes. In RelayPDF that client is @relaypdf/sdk. You construct RelayPDF with RELAYPDF_API_KEY, then call client.pdf.fromHtml or client.pdf.fromUrl. Those names are camelCase because the package is TypeScript. Python relaypdf uses from_html and from_url; do not copy snake_case into Node. The REST contract is the same: POST https://api.relaypdf.com/v1/pdf with exactly one source field.

This post is the Node path only. Product fields and print options live on https://relaypdf.com/html-to-pdf.

The method table, error types, and BinaryResult shape live on https://relaypdf.com/docs/sdks/node.

Zero runtime dependencies. Native fetch. Node 18 or newer LTS.

One render

Coding agents should run relaypdf setup and read RELAYPDF_API_KEY from the env file it writes.

Missing apiKey throws TypeError before any request.

The SDK does not retry. One method call is one HTTP request.

options.timeout max 60s is the Chromium render limit, not an SDK socket timeout.

import { RelayPDF } from "@relaypdf/sdk";
const client = new RelayPDF({
  apiKey: process.env.RELAYPDF_API_KEY!,
});
const pdf = await client.pdf.fromHtml(
  "<h1>Invoice #1042</h1><p>Total: $1,200.00</p>",
  { filename: "invoice.pdf" },
);
await pdf.save("invoice.pdf");
console.log(pdf.id, pdf.sizeBytes, pdf.contentType);

fromHtml sends the HTML string as UTF-8 JSON, not Base64. printBackground defaults to true on the API.

filename, when set, must end in .pdf. Default response is binary: kind is binary, bytes is a Uint8Array, save(path) writes the file in Node.

id comes from x-relaypdf-id. sizeBytes comes from x-relaypdf-size when present. filename on the result is parsed from content-disposition.

fromHtml versus fromUrl

Use fromHtml when you already have the markup. Use fromUrl when RelayPDF should fetch a public HTTPS page and print it.

Private, loopback, and metadata hosts are rejected with url_not_allowed. That is a documented 400, not a hang.

const pdf = await client.pdf.fromUrl("https://example.com", {
  filename: "page.pdf",
  options: {
    format: "A4",
    printBackground: true,
    waitUntil: "networkidle0",
  },
});
await pdf.save("page.pdf");

options is the Chromium print object. Documented format values include letter, legal, tabloid, ledger, and a0-a6.

waitUntil accepts load, domcontentloaded, networkidle0, and networkidle2. timeout max is 60000ms.

Header and footer HTML go on options.headerTemplate and options.footerTemplate. Placeholders follow Chromium: pageNumber, totalPages, date, title.

Give extra top and bottom margin or they clip. The same field names appear on REST.

const pdf = await client.pdf.fromHtml("<h1>Invoice</h1>", {
  options: {
    headerTemplate: "<div>Invoice</div>",
    footerTemplate: "<div>Page <span class=pageNumber></span></div>",
    margin: { top: "20mm", bottom: "20mm" },
  },
});

Local Chrome versus the SDK

The usual Node recipe runs Chrome on the same host as the app process, then prints the page to PDF.

You own the binary, cache directory, memory, and cleanup when the process dies mid-print.

On a long-lived VM that is a known cost. On Lambda or a small container it is the whole problem: download size, tmp space, and memory during print.

That is operational work. Every replica that can print needs a browser. Cold starts include browser start.

You pin renderer package versions to a Chrome build. Concurrent prints mean concurrent processes unless you build a queue.

fromHtml and fromUrl do not start a local browser. They POST JSON. Chromium runs in RelayPDF workers.

Your Node process stays a Node process: fetch, types, save. A local renderer is still right when you must attach to a page you already control in-process.

It is the wrong default for invoice PDF in a serverless handler.

URL results, async, and the rest of the client

Set response to url when you want JSON with a public download that expires in 24 hours. GET /v1/files/:id does not need the API key.

Set response to async for a 202. Poll with jobs.wait or jobs.get. Optional callbackUrl receives the job payload when it finishes. Failed operations are never billed.

const result = await client.pdf.fromHtml("<h1>Invoice</h1>", {
  response: "url",
  filename: "invoice.pdf",
});

if (result.kind === "url") {

  console.log(result.url, result.expiresAt, result.sizeBytes);
}

The same client maps 1:1 to the public API: fromMarkdown, fromTemplate, images.fromHtml, images.fromUrl, convert.fromPath, convert.fromHtml, pdf.merge, protect, extract.

convert is LibreOffice or wkhtmltopdf, not the Chromium HTML route. Do not send Office bytes to fromHtml. Office files go to POST /v1/convert. Merge existing PDFs is POST /v1/pdf/merge.

Errors

API errors throw RelayPDFError. Branch on error.code. status is the HTTP status. retryAfter is set on rate_limited.

payment_required means an empty wallet. url_not_allowed is a private or non-https target. payload_too_large is 413.

render_failed and processing_failed are 502 and unbilled. convert_unavailable is 503 on the document worker, not on fromHtml.

import { RelayPDF, RelayPDFError } from "@relaypdf/sdk";
try {
  await client.pdf.fromUrl("https://example.com");
} catch (error) { if (error instanceof RelayPDFError) {
    console.log(error.status, error.code, error.message, error.retryAfter);
} else { throw error;
  }
}

verifyWebhook is a named export. Pass the exact raw body string from your framework. Do not JSON.parse and re-stringify.

The secret is the dashboard webhook secret, not the API key. Header format is t=,v1=. Default clock-skew tolerance is 300 seconds.

WEBHOOK_SIGNATURE_HEADER is exported next to verifyWebhook.

What this is not

The SDK is server-side. Do not ship pdf_live_ keys to a browser bundle. health() does not send a key.

account() returns plan, rate tier, and wallet millicents and is not billed. Optional fetch on the constructor is for tests, undici, or Cloudflare.

Optional baseUrl is for a local API. There is no local-browser wrapper in the package.

Call fromHtml or fromUrl. Keep print options on the documented options object. Full method list: https://relaypdf.com/docs/sdks/node. HTML product page and curl: https://relaypdf.com/html-to-pdf.

Sibling product page for the same POST /v1/pdf html field is https://relaypdf.com/html-to-pdf. This article is the Node client, not a second REST spec.

Send exactly one source. The SDK helpers set that field for you. Mixing html and url in one create() is invalid_request.

Import RelayPDF from the official Node SDK. That is the html to pdf nodejs api this post is about.

Ready to generate?

One API for HTML, Markdown, URLs, and Office. REST, SDK, CLI, or MCP.