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:

shell
wkhtmltopdf --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:

curl
curl 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 flagKamy optionNotes
--page-size A4options.format: "a4"Also accepts a3, letter, legal.
--orientation Landscapeoptions.orientation: "landscape"Default portrait.
--margin-top 1cmoptions.margin.top: "1cm"Same units (cm/in/mm/px).
--margin-bottom 1cmoptions.margin.bottom: "1cm"—
--margin-left 1cmoptions.margin.left: "1cm"—
--margin-right 1cmoptions.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: trueOr 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-accessoptions.inlineImages: truePre-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.cssInline the CSS in your HTMLOr 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

Per-language migration snippets

TypeScript / JavaScript (official SDK)

typescript
import { 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

python
import 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

ruby
require "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.

Migrating from wkhtmltopdf to Kamy — drop-in HTML → PDF replacement — Kamy