HTML to PDF Converter Free: A Production API Guide
Compare free HTML to PDF options, production failure modes and runnable API examples. PDFGeny includes 50 documents monthly without a card.
A free HTML to PDF converter is practical when its limits match your workload. PDFGeny includes 50 documents per month without a card, while local tools such as Playwright and WeasyPrint have no per-document fee but require you to run and secure the rendering infrastructure.
TL;DR
- PDFGeny renders HTML, URLs or one of 40 document templates through a single API endpoint.
- The free plan includes 50 documents per month; additional documents cost $0.009 each.
- PDFGeny reports a 0.6-second median render time. A cold local Chromium process can take about 7.8 seconds, versus roughly 0.65 seconds when kept warm.
- Batch requests support up to 100 documents per call, while asynchronous jobs and signed webhooks handle work that should not block an HTTP request.
- A local library is often the better option for a handful of documents per day, especially if the HTML contains sensitive data and the application already runs Chromium.
Choosing Between a Free API and a Local Renderer
PDF generation has two separate costs: the visible document charge and the less visible cost of operating a browser. The second category includes browser installation, process recycling, font packages, memory limits, security patches and failed-job handling.
| Option | Direct cost | Operational responsibility | Best fit |
|---|---|---|---|
| PDFGeny free plan | 50 documents per month | Remote rendering; application handles API errors | Prototypes and low-volume production jobs |
| PDFGeny overage | $0.009 per document | Remote rendering, retries and webhook verification | Variable or growing workloads |
| Playwright or Puppeteer | No per-document software charge | Chromium, fonts, memory, updates and isolation | Existing browser infrastructure or sensitive HTML |
| WeasyPrint | No per-document software charge | Python dependencies, fonts and CSS compatibility | Print-focused layouts without browser JavaScript |
| wkhtmltopdf | No per-document software charge | Old rendering behavior and unsupported CSS | Stable legacy templates that already render correctly |
Local rendering wins at very low volume. If an internal application creates a handful of invoices each day and already has Playwright installed, adding an API introduces network dependency without necessarily removing meaningful work.
Hosted rendering becomes easier to justify when multiple services generate PDFs, traffic arrives in bursts or the team does not want a Chromium container in every deployment. The relevant comparison is not “free versus paid”; it is per-document cost versus browser ownership.
Calling the HTML-to-PDF API
PDFGeny accepts requests at POST https://pdfgeny.com/api/v1/render. The following examples send the same HTML and write the binary response to invoice.pdf. Store the API key in PDFGENY_API_KEY rather than committing it to source control.
cURL
curl --fail-with-body \
-X POST "https://pdfgeny.com/api/v1/render" \
-H "Authorization: Bearer $PDFGENY_API_KEY" \
-H "Content-Type: application/json" \
--data '{"html":"<h1>Invoice INV-1042</h1><p>Total: $49.00</p>"}' \
--output invoice.pdf
Python
import os
import requests
response = requests.post(
"https://pdfgeny.com/api/v1/render",
headers={"Authorization": f"Bearer {os.environ['PDFGENY_API_KEY']}"},
json={"html": "<h1>Invoice INV-1042</h1><p>Total: $49.00</p>"},
timeout=30,
)
response.raise_for_status()
with open("invoice.pdf", "wb") as pdf:
pdf.write(response.content)
Node.js
import { writeFile } from "node:fs/promises";
const response = await fetch("https://pdfgeny.com/api/v1/render", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.PDFGENY_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
html: "<h1>Invoice INV-1042</h1><p>Total: $49.00</p>"
})
});
if (!response.ok) {
throw new Error(`PDF render failed: ${response.status} ${await response.text()}`);
}
await writeFile("invoice.pdf", Buffer.from(await response.arrayBuffer()));
PHP
<?php
$ch = curl_init("https://pdfgeny.com/api/v1/render");
$payload = json_encode([
"html" => "<h1>Invoice INV-1042</h1><p>Total: $49.00</p>"
]);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("PDFGENY_API_KEY"),
"Content-Type: application/json"
],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FAILONERROR => true
]);
$pdf = curl_exec($ch);
if ($pdf === false) {
throw new RuntimeException(curl_error($ch));
}
file_put_contents("invoice.pdf", $pdf);
curl_close($ch);
Go
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
body := []byte(`{"html":"<h1>Invoice INV-1042</h1><p>Total: $49.00</p>"}`)
req, err := http.NewRequest(
http.MethodPost,
"https://pdfgeny.com/api/v1/render",
bytes.NewReader(body),
)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("PDFGENY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{}).Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
message, _ := io.ReadAll(res.Body)
panic(fmt.Sprintf("render failed: %s: %s", res.Status, message))
}
file, err := os.Create("invoice.pdf")
if err != nil { panic(err) }
defer file.Close()
_, err = io.Copy(file, res.Body)
if err != nil { panic(err) }
}
Ruby
require "net/http"
require "json"
uri = URI("https://pdfgeny.com/api/v1/render")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('PDFGENY_API_KEY')}"
request["Content-Type"] = "application/json"
request.body = {
html: "<h1>Invoice INV-1042</h1><p>Total: $49.00</p>"
}.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(request)
end
raise "Render failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
File.binwrite("invoice.pdf", response.body)
Failure mode: error responses get saved as corrupt PDFs. The fix is to check the HTTP status before writing the response body. Production clients should also set explicit timeouts and retry only transient failures, not malformed HTML or authentication errors.
Page Breaks, Fonts and Browser Timing
Failure mode: table rows and signatures split across pages. The fix starts with print-specific CSS rather than adding arbitrary spacer elements. Chromium understands modern paged-media properties, although complex tables still need representative tests.
<style>
@page {
size: A4;
margin: 12mm;
}
.invoice-row,
.signature,
.summary {
break-inside: avoid;
}
.new-page {
break-before: page;
}
@media print {
nav, .screen-only {
display: none;
}
}
</style>
MDN documents the @page rule and browser support. Keep old page-break-* declarations only when a legacy renderer still needs them.
Failure mode: web fonts silently fall back. The fix is to ensure fonts are reachable from the renderer and to wait for document.fonts.ready before printing. In a local Playwright process, the wait is explicit:
await page.setContent(html, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);
await page.pdf({
path: "invoice.pdf",
format: "A4",
printBackground: true
});
networkidle alone is not proof that a font has been applied. A stylesheet can load before its font file finishes, while blocked cross-origin requests and inaccessible private asset URLs create the same fallback symptom.
Failure mode: the PDF omits background colors. The fix is Chromium’s printBackground: true option or its API equivalent. Playwright’s public page.pdf() documentation lists the relevant print controls.
Production Security and Workload Design
Failure mode: URL-to-PDF becomes an SSRF endpoint. The fix is to resolve the hostname, reject private, loopback, link-local and cloud metadata addresses, and repeat validation after every redirect. A string check for localhost is inadequate because alternate IP formats and DNS rebinding can bypass it.
A renderer that can open arbitrary URLs can often reach resources that the public internet cannot. Treat URL rendering as outbound network access, not as a formatting feature.
The OWASP SSRF Prevention Cheat Sheet recommends allowlists where possible and explains why application and network controls should be combined.
Failure mode: a synchronous render exhausts web workers. The fix is to reserve synchronous generation for short interactive jobs and move reports or batch work to asynchronous jobs. PDFGeny supports both modes, signed HMAC webhooks and batches of up to 100 documents in one call.
Webhook receivers should verify the HMAC before parsing the event as trusted data. They should also be idempotent: delivery retries must not email the same contract twice or mark one invoice paid twice.
Failure mode: an ordinary PDF fails an archival requirement. The fix is to request the required conformance rather than renaming the file. PDFGeny uses Ghostscript for PDF/A-2b output, while Chromium is the default HTML engine and WeasyPrint is available as a second engine.
Challenge the Default: An API Is Not Always Better
A local library beats any API when document volume is small, deployment is controlled and keeping HTML inside the application boundary matters more than reducing maintenance. A command-line WeasyPrint process can be simpler than remote authentication, retries and vendor monitoring for a small internal system.
The same reasoning applies to existing Playwright installations. A warm browser can render in roughly 0.65 seconds, close to PDFGeny’s reported 0.6-second median. The operational distinction appears during cold starts: launching headless Chromium can cost about 7.8 seconds per request if the process is not reused.
wkhtmltopdf is not dead; it is unmaintained. The practical cost is the CSS it never learned. If a fixed invoice template already works, replacing it may create work with little benefit; if the design needs modern Grid, Flexbox or current browser behavior, template workarounds accumulate. The trade-offs are examined further in the production costs of wkhtmltopdf and the operating costs of Chrome-based PDF generation.
What We Got Wrong / What Surprised Us
The first mistaken assumption was that browser launch time was negligible. A cold headless Chromium request costs about 7.8 seconds, while keeping the browser warm reduces that figure to roughly 0.65 seconds. Browser reuse is therefore an architectural requirement, not a minor optimization.
The second surprise was framework interaction. Running sync_playwright inside Django’s asynchronous execution context raises SynchronousOnlyOperation unless rendering happens on its own thread. Switching libraries does not fix a mismatched concurrency model; isolation does.
The third mistake was treating successful CSS requests as proof of correct fonts. Fonts can still fall back if PDF generation starts before they finish loading. Visual regression fixtures should include characters whose shape clearly differs between the intended font and the fallback.
Practical Takeaways
- Run one API spike — estimated 15–30 minutes, easy. Render a real invoice rather than “Hello World.” Expected outcome: confirmation that authentication, binary responses and asset URLs work in the deployment environment.
- Create print fixtures — estimated 1–2 hours, moderate. Include a multi-page table, signature block, long unbroken value, web font and background color. Expected outcome: page-break and font failures appear before release.
- Choose sync or async — estimated 30 minutes, moderate. Keep interactive receipts synchronous; route reports and batches through asynchronous jobs and HMAC-signed webhooks. Expected outcome: web workers do not wait on long render queues.
- Review URL rendering — estimated half a day, difficult. Add host allowlists or IP-range rejection, redirect validation and outbound firewall rules. Expected outcome: users cannot turn the renderer into an internal network proxy.
- Compare monthly ownership — estimated 1 hour, moderate. Put the 50-document free allowance and $0.009 overage beside container, patching and incident costs. Expected outcome: the choice reflects total operating work rather than only software price.
Try PDFGeny Without Running Chromium
Send HTML, a URL or one of 40 templates and get a finished PDF in one API call—no Chromium process to keep warm and no fonts to install. The free plan includes 50 documents per month with no card required.
FAQ
What is the best free HTML-to-PDF converter for developers?
The best choice depends on ownership requirements. PDFGeny includes 50 documents per month without a card and removes browser maintenance. Playwright, Puppeteer and WeasyPrint have no per-document software fee but require deployment, fonts, security controls and process management.
Can HTML be converted to PDF without installing software?
Yes. A hosted API accepts HTML and returns a PDF over HTTPS. PDFGeny exposes POST https://pdfgeny.com/api/v1/render, uses headless Chromium by default and offers WeasyPrint as a second rendering engine.
Why does an HTML page look different in the PDF?
The usual causes are print CSS, unavailable assets, font timing and unsupported renderer features. Use @page, avoid breaks inside critical blocks, make assets reachable and wait for document.fonts.ready before printing.
Is URL-to-PDF safe?
URL-to-PDF is safe only with outbound request controls. Resolve hostnames, reject private and metadata addresses, validate redirects and restrict network access. Without those controls, the feature can become an SSRF path into internal services.