Migrating from wkhtmltopdf to Kamy
wkhtmltopdf has been archived since 2023, removed from Homebrew in December 2024, and carries an unpatched CVSS 9.8 vulnerability (CVE-2023-32991). The CiviCRM project issued a security advisory recommending all deployments switch to a maintained alternative.
Kamy is a drop-in replacement: a hosted HTTP API that takes the same kind of HTML you were already feeding into wkhtmltopdf and returns a PDF. It's built on a modern Chromium engine (the same one Chrome ships) so layout fidelity is high, fonts work out of the box, and the security surface is patched within hours of upstream.
The 30-second drop-in
If your existing pipeline looks like:
shellwkhtmltopdf --page-size A4 --margin-top 1cm \ --header-html header.html --footer-html footer.html \ invoice.html invoice.pdf
The Kamy equivalent is one HTTP call:
curlcurl https://kamy.dev/api/v1/render \ -H "Authorization: Bearer $KAMY_API_KEY" \ -H "Content-Type: application/json" \ -d @- <<'JSON' > invoice.pdf { "html": "<!doctype html><html>...</html>", "options": { "format": "a4", "margin": { "top": "1cm" }, "header": { "html": "<div>{{title}}</div>" }, "footer": { "html": "<div>Page {{page}} of {{pages}}</div>" } } } JSON
That's it. No binary to install, no missing fonts, no segfault on large tables, no headless display setup, no CVE.
CLI flag → API option translation
Every wkhtmltopdf flag you're likely to be using has a direct Kamy equivalent. Anything missing from this table either has no Chromium concept (e.g. --no-images) or is automatic in Kamy (e.g. waiting for images to load).
| wkhtmltopdf flag | Kamy option | Notes |
|---|---|---|
--page-size A4 | options.format: "a4" | Also accepts a3, letter, legal. |
--orientation Landscape | options.orientation: "landscape" | Default portrait. |
--margin-top 1cm | options.margin.top: "1cm" | Same units (cm/in/mm/px). |
--margin-bottom 1cm | options.margin.bottom: "1cm" | — |
--margin-left 1cm | options.margin.left: "1cm" | — |
--margin-right 1cm | options.margin.right: "1cm" | — |
--header-html https://... | options.header.html: "<div>...</div>" | Pass HTML inline (no extra HTTP fetch). Kamy expands {{page}}, {{pages}}, {{date}}, {{title}}, {{url}} tokens. |
--footer-html https://... | options.footer.html: "<div>...</div>" | Same token syntax. Default font/style is auto-applied. |
--header-center 'Page [page] of [topage]' | options.pageNumbers: true | Or use options.footer.html: '<div>Page {{page}} of {{pages}}</div>' for full control. |
--javascript-delay 1000 | (automatic) | Kamy waits for networkidle2 + every <img>.complete before printing — no manual delay needed. |
--enable-local-file-access | options.inlineImages: true | Pre-fetches remote <img> server-side so signed URLs and slow CDNs never race the print. |
--print-media-type | (default) | Kamy always prints with `printBackground: true`. |
--encoding UTF-8 | (default) | Chromium decodes per the document's <meta charset>. UTF-8 if unspecified. |
--user-style-sheet style.css | Inline the CSS in your HTML | Or reference it with an absolute URL inside <link rel='stylesheet'>. |
--zoom 1.0 | (use CSS `zoom` or `transform: scale()`) | Chromium's printToPDF doesn't expose a zoom flag — bake it into the HTML. |
(no equivalent) | options.direction: "auto" | "ltr" | "rtl" | Kamy detects/sets text direction so Arabic, Hebrew, Persian, Urdu render with correct block-level mirroring. |
(no equivalent) | options.encrypt: { userPassword, ownerPassword, permissions } | AES-256 password protection + per-action permission flags applied at save time. |
(no equivalent) | options.watermark: { text, opacity, color, angle } | Diagonal watermark stamped on every page. |
(no equivalent) | options.pdfA: "1b" | "2b" | "3b" | Best-effort PDF/A archival tagging with sRGB ICC OutputIntent + XMP. |
Common gotchas you no longer have to manage
- Page numbers. wkhtmltopdf needed
--header-center 'Page [page] of [topage]'; Kamy acceptsoptions.pageNumbers: trueor full custom HTML with{{page}}/{{pages}}tokens. - Headers being clipped. wkhtmltopdf required you to manually widen
--margin-topto fit the header. Kamy auto-widens the page margin toheader.height(defaults to1.5cm). - JavaScript timing. No more
--javascript-delayguesses — Kamy waits fornetworkidle2and every<img>to finish loading before printing. - Right-to-left scripts. wkhtmltopdf had partial bidi support that broke on tables and lists. Kamy ships
options.direction: 'auto'which detects Arabic / Hebrew / Persian / Urdu and appliesdir="rtl"so block-level mirroring works correctly. - Modern CSS. Flexbox, grid, custom properties, container queries,
@font-facewith web fonts,backdrop-filter— all work because the engine is Chromium, not a 2014 fork of QtWebKit.
Per-language migration snippets
TypeScript / JavaScript (official SDK)
typescriptimport { Kamy } from "@kamydev/sdk"; const kamy = new Kamy({ apiKey: process.env.KAMY_API_KEY! }); const result = await kamy.render({ html: invoiceHtml, options: { format: "a4", margin: { top: "1cm" }, header: { html: "<div>{{title}}</div>" }, footer: { html: "<div>Page {{page}} of {{pages}}</div>" }, }, }); await fs.writeFile("invoice.pdf", Buffer.from(result.pdf, "base64"));
Python
pythonimport os, base64, requests resp = requests.post( "https://kamy.dev/api/v1/render", headers={"Authorization": f"Bearer {os.environ['KAMY_API_KEY']}"}, json={ "html": invoice_html, "options": { "format": "a4", "margin": {"top": "1cm"}, "footer": {"html": "<div>Page {{page}} of {{pages}}</div>"}, }, }, ) resp.raise_for_status() with open("invoice.pdf", "wb") as f: f.write(base64.b64decode(resp.json()["pdf"]))
PHP
php$ch = curl_init("https://kamy.dev/api/v1/render"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KAMY_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "html" => $invoiceHtml, "options" => [ "format" => "a4", "footer" => ["html" => "<div>Page {{page}} of {{pages}}</div>"], ], ]), ]); $body = json_decode(curl_exec($ch), true); file_put_contents("invoice.pdf", base64_decode($body["pdf"]));
Ruby
rubyrequire "net/http" require "json" require "base64" resp = Net::HTTP.post( URI("https://kamy.dev/api/v1/render"), { html: invoice_html, options: { format: "a4", footer: { html: "<div>Page {{page}} of {{pages}}</div>" }, }, }.to_json, { "Authorization" => "Bearer #{ENV['KAMY_API_KEY']}", "Content-Type" => "application/json", }, ) File.binwrite("invoice.pdf", Base64.decode64(JSON.parse(resp.body)["pdf"]))
What about self-hosting?
If self-hosting was the reason you reached for wkhtmltopdf, the modern equivalents are Puppeteer or Playwright — both wrap the same Chromium engine Kamy uses. They work, but you become responsible for: keeping the Chromium binary patched, sizing the worker pool, sandboxing untrusted HTML, managing fonts, handling networkidle correctly, post-processing for watermarks / encryption / PDF/A, and absorbing the per-render memory cost (~150 MB peak). Kamy handles all of that for you and is cheaper than the EC2 instance you'd otherwise need.
Ready to switch?
The free tier covers 100 PDFs / month with no credit card. Average migration time from a working wkhtmltopdf pipeline is under 30 minutes.