To get html to pdf charts that are not empty boxes, print after the canvas or SVG is actually drawn. Chart.js paints a canvas element. Highcharts injects an svg element. Both run after script load. POST /v1/pdf already waits for waitUntil (default networkidle0). That is a network lifecycle, not a chart lifecycle. Add waitForSelector on a node that exists only when the chart has painted, or waitForTimeout for a short extra delay. Those four fields are waitUntil, waitForSelector, waitForTimeout, and timeout. They are the documented waits on https://relaypdf.com/docs/options. There is no waitForFunction, no chart-ready callback, and no notifyRender. The longer wait contract is https://relaypdf.com/blog/html-to-pdf-wait-for-javascript. Report layout and merge: https://relaypdf.com/use-cases/reports.
Send the report as html (UTF-8 JSON, not Base64) or as a published templateId. url only fetches a public http(s) host. A private dashboard URL returns url_not_allowed. Build the HTML on your server, same as any other Chromium print. Exactly one of html, url, markdown, templateId. filename, if set, must end in .pdf.
Why the chart is blank
The canvas with id sales is in the first HTML parse. The chart library then downloads, constructs the chart, and fills pixels. Highcharts does the same for SVG. networkidle0 can fire when the last CDN request has settled and the canvas is still white, or the SVG is still missing. If you wait only for #sales, you wait for an empty element. Wait for a marker you set after the library finishes, or for a child that the library creates.
Turn animations off in the chart config. A 400 ms ease looks fine in a tab and prints a half-drawn bar in a PDF. That is a library setting, not a RelayPDF option. printBackground defaults to true; leave it on or chart fills disappear.
Documented waits only
Field names are identical in JSON, Node, and Python. options.timeout is the Chromium render budget, max 60000 ms. It is not an SDK HTTP timeout. A render that exceeds it returns render_failed and is not billed. waitForTimeout is an extra sleep after the other waits, max 30000. waitForSelector is a CSS selector string or an object with selector, timeout, and visible. visible means the node is not display:none or visibility:hidden in the usual Chromium sense.
| Field | Default / limit | Use on charts |
|---|---|---|
| waitUntil | networkidle0; also load, domcontentloaded, networkidle2 | Keep default so CDN scripts finish |
| waitForSelector | CSS or { selector, timeout, visible } | Wait for SVG or a data-ready marker |
| waitForTimeout | extra ms, max 30000 | Small pad after paint, not a 30s sleep |
| timeout | render budget, max 60000 | Must cover scripts + waits |
Do not invent waitForXPath, waitForFunction, page.waitForChart, or a window.relaypdf hook. If the docs page does not list the field, do not send it. Sibling walkthrough of the same four fields: https://relaypdf.com/blog/html-to-pdf-wait-for-javascript.
Chart.js: wait for canvas paint
The canvas node is a poor selector. After the Chart constructor returns with animation disabled, set an attribute you can query. CSS attribute selectors are valid waitForSelector values. Example marker: element sales gets data-ready=1. Then waitForSelector is #sales[data-ready="1"] or the object form { selector, visible: true }.
Markup is a canvas with id sales and explicit width and height, plus the Chart.js CDN. After new Chart(...), call setAttribute data-ready 1. Use type bar, animation false, responsive false. responsive false plus explicit width and height keeps the bitmap from collapsing to a zero box when Chromium prints. Do not rely on a window resize after print.
Highcharts: wait for SVG
Highcharts mounts a .highcharts-container and writes an svg inside it. That svg is a real DOM child. waitForSelector #revenue svg is enough if you do not put a placeholder svg in the markup yourself. After Highcharts.chart(...) you can also set data-ready on #revenue the same way.
Give the container a fixed CSS size, for example width 640px and height 280px. A 100 percent width with no used height prints a collapsed plot. Set chart.animation false and series animation false. PreferCSSPageSize is at-page size only; it does not resize widgets.
Two-chart report
One HTML document, two figures, one POST. Wait for a page-level marker after both libraries have written their nodes. Set document.body data-charts=ready after both constructors return. Do not issue two /v1/pdf calls and merge unless the sections already live as separate PDFs. Office convert plus merge is a different path on the reports use case.
The HTML is a doctype document with both CDN tags, a heading, a flex row, canvas#sales, and div#revenue. Disable animations. Then POST with fromHtml and the documented waits.
import { RelayPDF } from "@relaypdf/sdk";
const client = new RelayPDF({ apiKey: process.env.RELAYPDF_API_KEY });
const html = renderTwoChartReport();
const pdf = await client.pdf.fromHtml(html, {
filename: "q4-report.pdf",
options: {
format: "A4",
landscape: true,
printBackground: true,
waitUntil: "networkidle0",
waitForSelector: { selector: "body[data-charts=ready]", visible: true },
waitForTimeout: 250,
timeout: 30000,
margin: { top: "12mm", right: "12mm", bottom: "14mm", left: "12mm" },
},
});
await pdf.save("q4-report.pdf");
Same options on curl. HTML in the JSON string is UTF-8, not Base64. The html value must include both charts and the body data-charts=ready marker.
curl -X POST https://api.relaypdf.com/v1/pdf
-H Authorization: Bearer pdf_live_...
-H Content-Type: application/json
-d {"html": "<html>two-chart report</html>", "filename": "q4-report.pdf", "options": {"format": "A4", "landscape": true, "printBackground": true, "waitUntil": "networkidle0", "waitForSelector": {"selector": "body[data-charts=ready]", "visible": true}, "waitForTimeout": 250, "timeout": 30000}}
Host is https://api.relaypdf.com. Bearer pdf_live_.... Query-string keys are rejected. Default response is binary (200 application/pdf, x-relaypdf-id, x-relaypdf-size, content-disposition). response url returns JSON with GET /v1/files/:id, 24 hours, no key. response async returns 202; poll GET /v1/jobs/:id. callbackUrl must be https. HTML/URL/Markdown/template debit is 0.015 USD on success. Failed jobs, 429, and 402 are not billed.
Page, color, and media
format defaults to letter; the sample uses A4. landscape is a boolean. margin is CSS lengths. scale is 0.1 to 2. headerTemplate and footerTemplate are Chromium fragments with pageNumber, totalPages, date, title. Add extra margin so they are not clipped. pageRanges is like 1-3,5. There is no documented mediaType or emulateMedia field. Write print CSS yourself. Flex and canvas work because the engine is Chromium, not wkhtmltopdf. Convert (LibreOffice / wkhtml) is POST /v1/convert and will not run Chart.js the same way.
CDN script tags must be reachable from the render workers. Self-host the libraries in the HTML or on a public origin if you cannot depend on jsDelivr or code.highcharts.com. Inline the data. Do not have the printed page call your private API; that fetch is not your user session.
Errors
invalid_request for mixed sources, a filename that is not .pdf, or an option outside the documented range. url_not_allowed for a private url. render_failed when Chromium times out waiting for a selector that never appears, usually a typo in the marker, a blocked CDN, or animation still running when timeout fires. Those jobs are not billed. payment_required on an empty wallet. rate_limited with Retry-After (trial 20/min, funded 60/min). Errors are error.code and error.message. Node throws RelayPDFError with the same code.
Reports, not screenshots
This post is Chromium print of HTML that happens to contain charts. POST /v1/images is the screenshot API (png, jpeg, webp) with the same waitUntil, waitForSelector, and waitForTimeout fields. Use images when you want a PNG of one widget. Use /v1/pdf when you want a multi-section report with print CSS. If the sources are Word or Excel already, convert those files and merge; that is the reports workflow, not Chart.js. Product: https://relaypdf.com/use-cases/reports. PDF fields: https://relaypdf.com/docs/pdf. Options: https://relaypdf.com/docs/options.
Ship it
Build one HTML report. Disable chart animation. Mark the body when both Chart.js and Highcharts have written. POST /v1/pdf with waitUntil networkidle0, waitForSelector on that marker, a short waitForTimeout, and a timeout that covers the CDNs. Do not send undocumented waits. CTA: https://relaypdf.com/use-cases/reports. Wait reference: https://relaypdf.com/blog/html-to-pdf-wait-for-javascript.