Migrating off wkhtmltopdf to Headless Chrome: A Practical Guide for 2026
Summary. wkhtmltopdf has been archived since January 2023 and renders with a WebKit engine that stopped getting updates around 2012. This guide moves a working wkhtmltopdf setup to Headless Chrome: a flag-by-flag mapping table, the CSS and JavaScript changes that matter, headers and footers, and a Python script that translates your existing wkhtmltopdf options and renders both engines side by side so you can check every template before you switch. On Api2Pdf, the move means changing one endpoint path and the options object. Your API key, response format and pricing stay the same.
Why move now
- It is unmaintained. The wkhtmltopdf repository was archived on January 2, 2023. The last release is 0.12.6 (June 2020), and the maintainer's status page explains that the Qt 4 WebKit it is built on has not been updated since 2012.
- The engine is old. No CSS Grid, no CSS custom properties, and only the old
display: -webkit-boxflexbox syntax. JavaScript is ES5, solet, arrow functions,fetchand most modern chart library builds fail or need polyfills. - Security. CVE-2022-35583 (SSRF through injected iframes) was never fixed upstream. The maintainers' position is that you must sandbox wkhtmltopdf yourself when it renders untrusted HTML. Ubuntu stopped shipping the package as of 25.04.
- Your templates are harder to work on. Anything your front-end team builds today is tested in a modern browser. Headless Chrome renders it the same way the browser does.
wkhtmltopdf still has a couple of real advantages: it is fast on simple documents, it can build a table of contents page for you, and its PDFs are often smaller (see our size comparison). If those matter more to you than modern CSS, keep it for those documents. Api2Pdf still runs both engines.
Step 1: swap the endpoint
Both engines take the same request body except options, and they return the same response JSON. Here is a typical wkhtmltopdf call:
curl -X POST "https://v2.api2pdf.com/wkhtml/pdf/html" \
-H "Authorization: YOUR-API-KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Invoice #1042</h1>",
"fileName": "invoice-1042.pdf",
"inline": false,
"options": {
"pageSize": "Letter",
"orientation": "portrait",
"marginTop": "20mm",
"marginBottom": "20mm",
"footerCenter": "Page [page] of [topage]",
"footerFontSize": "9"
}
}'
The same document on Headless Chrome:
curl -X POST "https://v2.api2pdf.com/chrome/pdf/html" \
-H "Authorization: YOUR-API-KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Invoice #1042</h1>",
"fileName": "invoice-1042.pdf",
"inline": false,
"options": {
"width": "8.5in",
"height": "11in",
"landscape": false,
"marginTop": "0.79in",
"marginBottom": "0.79in",
"printBackground": true,
"displayHeaderFooter": true,
"headerTemplate": "<span></span>",
"footerTemplate": "<div style=\"font-size:9px;width:100%;text-align:center;\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
}
}'
URL conversions work the same way: /wkhtml/pdf/url becomes /chrome/pdf/url, and the Chrome endpoint also accepts extraHTTPHeaders if the page you are converting needs a cookie or bearer token. Both endpoints return Success, FileUrl (hosted for 24 hours), MbOut, Seconds, Cost and ResponseId. Add ?outputBinary=true to get the PDF bytes back instead.
Step 2: translate the options
Api2Pdf's wkhtmltopdf options are the CLI flags in camelCase (--margin-top becomes marginTop). The Chrome options are Chrome's print-to-PDF settings. Full references: wkhtmltopdf options and Headless Chrome options.
| wkhtmltopdf CLI | Api2Pdf wkhtml option | Headless Chrome option |
|---|---|---|
--page-size Letter | pageSize | width: "8.5in", height: "11in" (default is A4, 8.27in x 11.69in), or CSS @page { size: letter; } plus preferCSSPageSize: true |
--page-width / --page-height | pageWidth / pageHeight | width / height |
-O landscape | orientation | landscape: true |
-T -B -L -R | marginTop etc. (mm) | marginTop etc. (default .4in; 10mm is about 0.39in) |
--header-left/center/right, --footer-* | headerCenter, footerRight, ... | displayHeaderFooter: true + headerTemplate / footerTemplate (HTML) |
--header-html URL | headerHtml | headerTemplate with the HTML inline (it cannot be a URL) |
--zoom 0.8 | zoom | scale: 0.8 (0.1 to 2) |
--javascript-delay 2000 | n/a | delay: 2000, or better, puppeteerWaitForMethod (below) |
--print-media-type | n/a | On by default: Chrome prints with @media print rules |
--no-background | n/a | printBackground: false |
toc | enableToc, tocOptions | No generated TOC page. outline: true adds PDF bookmarks from your headings |
--title | title | The page's <title> |
--grayscale, --dpi, --image-quality, --lowquality | same names | No equivalent. Use a grayscale stylesheet for color, and resize or compress images before rendering |
--page-offset | pageOffset | No equivalent. pageNumber always starts at 1 |
Step 3: fix what renders differently
@media print now applies
wkhtmltopdf uses screen styles unless you pass --print-media-type. Chrome uses print styles. If your app's stylesheet hides navigation, buttons or "whole page" wrappers under @media print, those rules now take effect in your PDFs. That is usually what you want, but check templates that share CSS with the web app.
Delete the old WebKit workarounds
Templates built for wkhtmltopdf are full of workarounds: display: -webkit-box for flex layouts, tables for columns, floats with clearfix. Chrome still renders most of them, but replacing them with flexbox or grid is what makes the templates maintainable. A two-column invoice header is now just:
.invoice-head { display: flex; justify-content: space-between; align-items: flex-start; }
.line-items { display: grid; grid-template-columns: 1fr auto auto; gap: 4px 24px; }
:root { --brand: #00a3cf; }
h1 { color: var(--brand); }
Page breaks still work with page-break-before / page-break-after, and Chrome also supports the modern break-before: page and break-inside: avoid. Table headers repeat across pages without any extra CSS. More in Print page breaks with wkhtmltopdf or Headless Chrome.
Scale and sizing
wkhtmltopdf turns on "smart shrinking" by default, and many teams tuned zoom or dpi until things looked right. Chrome lays out at standard CSS pixels (96 per inch), so a template tuned for wkhtmltopdf often comes out a little larger and can spill onto an extra page. Compare the page counts first (the script below does this). If a template was built around wkhtmltopdf's scaling, try a scale between 0.8 and 0.9 before rewriting the CSS.
Wait for JavaScript properly
Chart libraries that needed --javascript-delay (and an ES5 build) now run their modern builds. Instead of guessing a delay, have the page tell Chrome when it is done. Set a marker when rendering finishes:
<script>
renderCharts().then(() => {
document.body.insertAdjacentHTML("beforeend", '<div id="render-done"></div>');
});
</script>
and wait for that selector:
"options": {
"puppeteerWaitForMethod": "WaitForSelector",
"puppeteerWaitForValue": "#render-done"
}
For URL conversions you can also use "puppeteerWaitForMethod": "WaitForNavigation" with "puppeteerWaitForValue": "networkidle0" (also accepts load, domcontentloaded, networkidle2). A request gets 90 seconds; the V2 XL endpoints allow 5 minutes.
Headers and footers
This is the biggest change. In wkhtmltopdf, header text was a string with [page]-style variables or a separate HTML page fetched by URL. In Chrome, the header and footer are small HTML snippets passed inline, and Chrome fills in elements with these classes: pageNumber, totalPages, title, url, date. Three things trip everyone up:
- Set a font size. The template does not inherit your page CSS, and without an explicit
font-sizethe text is very small. - Leave room for them. The header and footer are drawn inside the page margins, so
marginTop/marginBottommust be large enough, or they are hidden behind the content. - Inline everything. External stylesheets and image URLs in the template are not loaded. Use inline
styleattributes and base64data:URIs for logos. Background colors also need-webkit-print-color-adjust: exact(see this post).
If you only need a footer, pass an empty header, "headerTemplate": "<span></span>". Api2Pdf already defaults to an empty template, but plain Chrome and Puppeteer print the date and title when it is left out, so being explicit keeps the template portable.
| wkhtmltopdf variable | Chrome template element |
|---|---|
[page] | <span class="pageNumber"></span> |
[topage] | <span class="totalPages"></span> |
[title], [doctitle] | <span class="title"></span> |
[webpage] | <span class="url"></span> |
[date], [isodate] | <span class="date"></span> (formatted by Chrome) |
[section], [subsection], [frompage], [sitepage], [sitepages] | No equivalent |
Fonts
Don't rely on fonts installed on the rendering machine. Load web fonts with @font-face or a Google Fonts <link> so the output is the same everywhere. See Using custom fonts with wkhtmltopdf and Headless Chrome.
Step 4: migrate with a side-by-side check
This script does the mechanical part. to_chrome_options() converts an Api2Pdf wkhtmltopdf options dict into Chrome options, builds header and footer templates from headerLeft / headerCenter / footerRight and the other text options, and raises on anything it cannot translate so nothing is dropped silently. compare() renders one template with both engines and prints page count, size, time and cost from each response.
pip install requests pypdf
# migrate_wkhtml.py
import html as html_lib
import io
import os
import re
import requests
from pypdf import PdfReader
API = "https://v2.api2pdf.com"
HEADERS = {"Authorization": os.environ["API2PDF_API_KEY"]}
PAGE_SIZES = { # inches, portrait
"A3": ("11.69in", "16.54in"), "A4": ("8.27in", "11.69in"), "A5": ("5.83in", "8.27in"),
"Letter": ("8.5in", "11in"), "Legal": ("8.5in", "14in"), "Tabloid": ("11in", "17in"),
}
VARIABLES = {
"[page]": '<span class="pageNumber"></span>', "[topage]": '<span class="totalPages"></span>',
"[title]": '<span class="title"></span>', "[doctitle]": '<span class="title"></span>',
"[webpage]": '<span class="url"></span>', "[date]": '<span class="date"></span>',
"[isodate]": '<span class="date"></span>',
}
IGNORED = {"dpi", "imageDpi", "imageQuality", "lowquality", "noHeaderLine", "noFooterLine",
"headerSpacing", "footerSpacing", "headerFontName", "footerFontName"}
def to_inches(value, default):
"""'20mm' / '2cm' / '1in' / 20 (wkhtmltopdf default unit is mm) -> '0.79in'."""
if value is None:
return default
m = re.fullmatch(r"\s*([\d.]+)\s*(mm|cm|in)?\s*", str(value))
if not m:
raise ValueError(f"unsupported margin {value!r}")
n, unit = float(m.group(1)), m.group(2) or "mm"
inches = n / 25.4 if unit == "mm" else n / 2.54 if unit == "cm" else n
return f"{inches:.2f}in"
def band(opts, prefix, side_margin):
"""Build a Chrome header/footer template from wkhtml *Left/*Center/*Right text."""
cells = []
for side in ("Left", "Center", "Right"):
text = html_lib.escape(str(opts.get(prefix + side, "")))
for var, el in VARIABLES.items():
text = text.replace(var, el)
cells.append(f'<div style="flex:1;text-align:{side.lower()};">{text}</div>')
if not any(opts.get(prefix + s) for s in ("Left", "Center", "Right")):
return None
size = float(opts.get(prefix + "FontSize", 12)) # wkhtmltopdf sizes are points
line = "border-top:1px solid #999;" if prefix == "footer" and opts.get("footerLine") else ""
line += "border-bottom:1px solid #999;" if prefix == "header" and opts.get("headerLine") else ""
return (f'<div style="display:flex;box-sizing:border-box;width:100%;padding:0 {side_margin};'
f'font-family:Arial,sans-serif;font-size:{size:g}pt;">'
f'<div style="display:flex;width:100%;padding:2px 0;{line}">{"".join(cells)}</div></div>')
def to_chrome_options(wk):
wk = dict(wk or {})
for key in ("headerHtml", "footerHtml", "pageOffset", "grayscale"):
if key in wk:
raise ValueError(f"{key} needs a manual change, see the mapping table")
out = {"printBackground": True}
if "pageWidth" in wk or "pageHeight" in wk:
out["width"] = to_inches(wk.get("pageWidth"), "8.27in")
out["height"] = to_inches(wk.get("pageHeight"), "11.69in")
else:
size = wk.get("pageSize", "A4")
if size not in PAGE_SIZES:
raise ValueError(f"add {size!r} to PAGE_SIZES")
out["width"], out["height"] = PAGE_SIZES[size]
out["landscape"] = str(wk.get("orientation", "portrait")).lower() == "landscape"
# wkhtmltopdf documents a 10mm default for left/right; use about 10mm on every side
out["marginTop"] = to_inches(wk.get("marginTop"), "0.39in")
out["marginBottom"] = to_inches(wk.get("marginBottom"), "0.39in")
out["marginLeft"] = to_inches(wk.get("marginLeft"), "0.39in")
out["marginRight"] = to_inches(wk.get("marginRight"), "0.39in")
if "zoom" in wk:
out["scale"] = max(0.1, min(2.0, float(wk["zoom"])))
header, footer = band(wk, "header", out["marginLeft"]), band(wk, "footer", out["marginLeft"])
if header or footer:
out["displayHeaderFooter"] = True
out["headerTemplate"] = header or "<span></span>"
out["footerTemplate"] = footer or "<span></span>"
known = {"pageSize", "pageWidth", "pageHeight", "orientation", "marginTop", "marginBottom",
"marginLeft", "marginRight", "zoom", "title", "headerLine", "footerLine"}
known |= {p + s for p in ("header", "footer") for s in ("Left", "Center", "Right", "FontSize")}
leftover = set(wk) - known - IGNORED
if leftover:
raise ValueError(f"untranslated wkhtml options: {sorted(leftover)}")
return out
def render(engine, html, options):
r = requests.post(f"{API}/{engine}/pdf/html", headers=HEADERS, timeout=120,
json={"html": html, "inline": False, "options": options})
r.raise_for_status()
data = r.json()
if not data.get("Success"):
raise RuntimeError(f"{engine}: {data.get('Error')}")
pdf = requests.get(data["FileUrl"], timeout=60).content
return len(PdfReader(io.BytesIO(pdf)).pages), data
def compare(name, html, wk_options):
chrome_options = to_chrome_options(wk_options)
for engine, opts in (("wkhtml", wk_options), ("chrome", chrome_options)):
pages, d = render(engine, html, opts)
print(f"{name:20} {engine:7} pages={pages:<3} MB={d['MbOut']:.3f} "
f"s={d['Seconds']:.2f} cost=${d['Cost']:.5f} {d['FileUrl']}")
if __name__ == "__main__":
invoice = open("templates/invoice.html", encoding="utf-8").read()
compare("invoice", invoice, {
"pageSize": "Letter", "marginTop": "20mm", "marginBottom": "20mm",
"footerCenter": "Page [page] of [topage]", "footerFontSize": "9",
})
Run it over every template you have. Then open each pair of FileUrl links (they are hosted for 24 hours) and look for page count differences, scaling, and header or footer overlap. When a template matches, switch its call to /chrome/pdf/html with the translated options. Templates can move one at a time, because nothing else about the request changes.
In Node, the official api2pdf package (npm install api2pdf) has a method for each engine, so the change in your code is one method name and the options object:
// invoice.mjs
import Api2Pdf from "api2pdf";
import { readFile } from "node:fs/promises";
const client = new Api2Pdf(process.env.API2PDF_API_KEY);
const html = await readFile("templates/invoice.html", "utf8");
// before
const old = await client.wkHtmlToPdf(html, {
fileName: "invoice-1042.pdf",
options: { pageSize: "Letter", marginTop: "20mm", footerCenter: "Page [page] of [topage]" },
});
// after
const result = await client.chromeHtmlToPdf(html, {
fileName: "invoice-1042.pdf",
options: {
width: "8.5in",
height: "11in",
marginTop: "0.79in",
printBackground: true,
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate:
'<div style="font-size:9px;width:100%;text-align:center;">' +
'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
},
});
console.log(result.FileUrl);
// on an API error the promise rejects with the response JSON, e.g. { Success: false, Error: "..." }
In Python, the api2pdf package passes extra keyword arguments through as options, so the translated dict drops straight in: client.Chrome.html_to_pdf(html, file_name="invoice-1042.pdf", **to_chrome_options(wk_options)).
What it costs
Both engines are billed the same way on Api2Pdf: $0.00019551 per second of compute plus $0.001 per MB of output (pricing). Chrome PDFs are sometimes larger and can take a little longer on heavy pages, so read Seconds and MbOut from the comparison run. For most templates, the difference is a fraction of a cent per thousand documents. If you are deciding whether to run Chrome yourself instead, see Puppeteer vs Playwright vs HTML-to-PDF API: the real cost of PDF generation in 2026.
Migration checklist
- List every call to
/wkhtml/pdf/htmland/wkhtml/pdf/urland the options each one sends. - Translate options with the table or
to_chrome_options(). RewriteheaderHtml/footerHtmlas inline templates. - Set
marginTop/marginBottomlarge enough for the header and footer, and give the templates an explicitfont-size. - Check
@media printrules, since Chrome applies them. - Replace fixed JavaScript delays with
WaitForSelector. - Compare page counts and output side by side, then switch templates one at a time.
- Once a template is on Chrome, remove the WebKit workarounds and ES5 builds it no longer needs.
Before sending a template to the API, open it in desktop Chrome and use print preview. It uses the same engine, so what you see is close to what you will get (Test your HTML locally first).
New accounts get one month of free credit. Get an API key at portal.api2pdf.com, run the comparison on your own templates, and switch the ones that match.
Related reading:



