If you’re shopping for an HTML to PDF API in 2026, the market has quietly converged on a single architecture. Almost every serious vendor runs the same headless browser (Chromium), speaks the same basic request shape (POST some HTML, get back a PDF), and argues over a handful of feature knobs — margins, headers, encryption, webhooks, regions. The differences that actually matter to a working engineer are smaller than the marketing suggests, and they’re hidden under layers of feature-list bingo.
This guide cuts through that. It’s the piece I wish I had when we were designing 21pdf — written by the team that built it, grounded in the Go codebase that powers it, and honest about what an HTML to PDF API can and cannot do.
TL;DR
- An HTML to PDF API is a REST service that renders HTML to a PDF, usually via headless Chromium.
- The only rendering engines worth paying attention to in 2026 are Chromium (the one) and Prince XML (niche, print-shop grade, expensive). Everything else is dead or dying.
- The six features that actually matter: CSS @page support, network-idle waiting, SSRF handling, quota + concurrency gates, async job model, and predictable pricing.
- Managed APIs make sense until your volume crosses ~100k PDFs/day. Self-hosting Chromium at scale is a real operational commitment.
- You rarely need webhooks, signed URLs, or S3 direct-upload on day one. Start with synchronous
POST /convert→ poll → download, then add complexity when you have the scars to justify it.
What an HTML to PDF API actually does
At its core, a modern HTML to PDF API is a thin wrapper around a headless browser. You send it HTML (or a URL, or a fragment), it launches a Chromium process, loads the page, waits for it to settle, and calls page.pdf() or the equivalent DevTools Protocol command. The PDF bytes come out the other end.
The interesting parts are everything around that core render loop:
- Accepting input safely — raw HTML, a URL to fetch, or a fragment. URL inputs need SSRF defences. Raw HTML can still reference external assets that need the same defences.
- Managing Chromium lifecycle — cold starts, browser crashes, zombie tabs, out-of-memory kills. You don’t want a browser per request; you don’t want one browser forever either.
- Handling back-pressure — quotas per customer, concurrent-render limits, graceful degradation when the queue deepens.
- Turning the raw PDF bytes into something deliverable — synchronous response, signed URL, storage upload, webhook callback.
A vendor’s real differentiation lives in those four concerns. The actual rendering — the call that produces PDF bytes from HTML — is essentially commodity. Everyone is using Chromium; the differences are microseconds on a warm process.
The three input shapes every API offers
Virtually every HTML-to-PDF API exposes three input modes:
| Input | Use when | Caveat |
|---|---|---|
html (full document) | Your service already has the rendered HTML in memory | Large payloads eat bandwidth; 1-5MB is normal, 50MB is a smell |
url | The page you want to print is already published somewhere | SSRF risk; slower by a round-trip |
simple_html / fragment | You have just a chunk and don’t want to write boilerplate | API wraps it in a minimal document; sometimes strips things |
21pdf accepts all three under a single POST /v1/convert endpoint. Most competitors do too; they just call the fields different names. Some APIs push you toward one shape (usually url) because it’s easier for them to cache — be wary. You want to send whichever shape is fastest for your system, which is usually raw HTML.
The engines that matter in 2026
The HTML-to-PDF rendering engine landscape has been consolidating for five years. Here’s the state of play.
Chromium (effectively: all of them)
Puppeteer and Playwright are the two common automation layers; underneath, both drive Chromium via the DevTools Protocol. API vendors generally use one or the other, or talk CDP directly.
Chromium wins because:
- It supports every modern CSS feature: flex, grid, container queries,
@page, subgrid, variable fonts, print-colour-adjust, and so on. - It’s the same engine users browse with, so WYSIWYG is a real phrase rather than marketing.
- The print output is excellent — page counters, running headers, CSS hyphenation all work.
- It’s free and patched aggressively (important because rendering untrusted HTML is a security-sensitive operation).
If you’re evaluating an HTML-to-PDF API and the vendor can’t tell you what Chromium version they run, that’s a bad sign. At 21pdf we pinned our base image after Alpine’s Chromium 131 package started segfaulting on certain fonts — the version matters.
Prince XML (niche, worth knowing)
Prince is the oldest actively-maintained HTML-to-PDF engine and the only serious alternative. It implements CSS Paged Media Level 3 more completely than Chromium does, supports advanced typography (proper ligatures, small caps, drop caps with @page flow), and produces tighter PDF output.
It’s also expensive ($3,800+ for a server licence) and single-threaded, so you need one licence per renderer. Book publishers, legal typesetters, and airline-ticket printers use it. Almost no SaaS uses it directly.
DocRaptor is the largest API in front of Prince. If your output must be print-shop quality and pixel-identical to a designer’s mockup, they’re worth the premium. If you’re rendering invoices and statements, Prince is a sledgehammer for a finishing nail.
wkhtmltopdf (dead; don’t start here)
wkhtmltopdf was the dominant open-source tool until 2020. It’s based on a 2013-era WebKit snapshot that upstream abandoned — no flex, no grid, no modern @page. The project entered deprecation in 2023 and is effectively unmaintained.
We removed wkhtmltopdf from 21pdf in April 2026. Every conversion now goes through Chromium. If a vendor still prominently features “wkhtmltopdf compatibility” in their marketing, treat that as a code-smell — they have legacy users they don’t want to break, which means their product is also legacy.
Weasyprint, Gotenberg, others
- Weasyprint is a Python-based renderer with decent
@pagesupport but significantly lagging CSS (no container queries, no subgrid, limited JS). Fine for simple reports; wrong for anything complex. - Gotenberg is a Dockerised Chromium wrapper — basically the self-hosted version of the APIs you’re evaluating. Good project; you end up operating it yourself.
- LaTeX pipelines still exist in academic and publishing contexts but aren’t an HTML-to-PDF answer.
The only conversation that’s realistic for most teams in 2026 is Chromium-based APIs vs self-hosted Chromium. Everything else is a special case.
The six features that actually matter
Marketing pages will list thirty features. Most don’t move the decision. Here are the six that do.
1. CSS @page and paged-media support
The @page rule and its relatives (@page :first, @page :left, size, margin, page-break-before, break-inside) are how you control PDF layout from CSS. Without them you’re stuck with API-level knobs (margin_top: 20, orientation: portrait) that won’t express anything complex.
A good API lets your @page rules override its API-level knobs — i.e., if you specify size: A3 landscape in CSS, that wins over options.page_size: "A4" passed in the request. 21pdf does this because Chromium does this; you should confirm with any vendor you evaluate.
2. Network-idle waiting
Modern pages fetch data after first paint. If your renderer captures the PDF the moment DOMContentLoaded fires, you get a blank skeleton. The fix is a wait_for_network_idle option (sometimes called network_idle, wait_until: 'networkidle0', or idle_ms) that holds the render until XHRs settle.
Some APIs let you pin the wait to a CSS selector (wait_for: '.report-ready'), which is strictly better — “the element I actually need appeared” is a more reliable signal than “the network was quiet for 500ms”. Confirm which mechanism your candidate API supports.
3. SSRF handling
If your API accepts url inputs, it’s a server-side request forgery vector. A malicious customer can ask it to render http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS metadata) or http://localhost:5432 (your vendor’s internal Postgres) and the PDF response bytes will leak whatever’s there.
The real defence is two layers:
- HTTP boundary block: reject any URL that resolves to
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fe80::/10, or link-local before issuing the fetch. - Chromium request interceptor: inside the browser, install a request handler on the page that re-checks every sub-request the page makes. A page can resolve a DNS name that currently points to a public IP but, by the time Chromium fetches it, has been reassigned to a private one (DNS rebinding). Only a re-check inside the browser catches this.
At 21pdf we do both. Many competitors do only the first. You can confirm by asking — if the answer is “we block private IPs at the HTTP layer” full stop, they haven’t thought hard enough.
4. Quota and concurrency gates
An HTML-to-PDF API needs two separate rate limits:
- Quota — total PDFs per billing cycle. Used for plan tiering.
- Concurrency — simultaneous in-flight renders per customer. Used to prevent noisy neighbours.
These are different knobs. Without a concurrency gate, a single customer can submit 10,000 parallel jobs and push every other customer’s queue time through the roof. A good API will expose both limits on every error response (X-Quota-Used: 412, X-Quota-Limit: 10000, Retry-After: 5) so your client can self-rate-limit.
At 21pdf the quota gate lives in middleware in front of POST /v1/convert and returns 402 Payment Required when exhausted; the concurrency gate returns 429 with Retry-After. Two different status codes for two different classes of back-pressure; don’t conflate them.
5. Async job model (don’t block your API on our rendering)
A Chromium render takes 200-3,000ms depending on the page. If your API call is a synchronous request/response, you’re tying up an HTTP connection for that whole window — both on your side and on the vendor’s. At scale this becomes a problem for both of you.
The robust shape is async:
POST /v1/convert → 202 Accepted + { job_id }
GET /v1/jobs/:id → { status, pdf_url? } (poll or SSE)
GET /v1/jobs/:id/download → PDF binary
This is what 21pdf does by default. Some APIs offer sync as an option (block for up to 30 seconds, get the PDF back in the response). That’s fine for dev work; for production, async is safer — your HTTP server isn’t holding 10,000 open connections waiting for Chromium processes.
6. Predictable, inspectable pricing
You want an API whose pricing page is a table, not a calculator. Pay-per-PDF sounds flexible; in practice it makes cost prediction harder and opens the door to surprise bills during traffic spikes.
Flat subscription tiers with a clear over-quota behaviour (hard 402 at 21pdf; soft throttle elsewhere) are easier to reason about. Inspect the over-quota path specifically — if you can’t tell what happens at your quota’s 10,001st PDF, that’s a contract you can’t plan around.
Shape of a real production integration
Here’s the minimum viable integration with an HTML-to-PDF API. It’s what I’d build first, and it’s what works for 80% of production workloads.
Client side
# Single curl to submit a render.
curl -X POST https://login.21pdf.com/v1/convert \
-H "Authorization: Bearer $QPDF_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Invoice #2041</h1>...",
"options": {
"page_size": "A4",
"margin_top": 20, "margin_bottom": 20,
"margin_left": 15, "margin_right": 15,
"wait_for_network_idle": true
}
}'
Response:
{ "job_id": "job_01HW...", "status": "queued" }
Then poll until status is succeeded and download. In Node:
async function renderPdf(html) {
const sub = await fetch('https://login.21pdf.com/v1/convert', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.QPDF_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
options: { page_size: 'A4', wait_for_network_idle: true },
}),
}).then(r => r.json());
// Poll every 500ms, capped at 30s.
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 500));
const s = await fetch(`https://login.21pdf.com/v1/jobs/${sub.job_id}`, {
headers: { 'Authorization': `Bearer ${process.env.QPDF_KEY}` },
}).then(r => r.json());
if (s.status === 'succeeded') {
return fetch(`https://login.21pdf.com/v1/jobs/${sub.job_id}/download`, {
headers: { 'Authorization': `Bearer ${process.env.QPDF_KEY}` },
}).then(r => r.arrayBuffer());
}
if (s.status === 'failed') throw new Error(s.message);
}
throw new Error('timeout');
}
That’s it. 40 lines, no SDK, works against every HTTP-clean API with cosmetic adjustments.
In Python it’s even shorter:
import os, time, requests
def render_pdf(html: str) -> bytes:
key = os.environ["QPDF_KEY"]
headers = {"Authorization": f"Bearer {key}"}
sub = requests.post(
"https://login.21pdf.com/v1/convert",
headers={**headers, "Content-Type": "application/json"},
json={"html": html, "options": {"page_size": "A4", "wait_for_network_idle": True}},
).json()
for _ in range(60):
time.sleep(0.5)
s = requests.get(f"https://login.21pdf.com/v1/jobs/{sub['job_id']}", headers=headers).json()
if s["status"] == "succeeded":
return requests.get(
f"https://login.21pdf.com/v1/jobs/{sub['job_id']}/download",
headers=headers,
).content
if s["status"] == "failed":
raise RuntimeError(s["message"])
raise TimeoutError()
Server side (what the vendor is doing)
When your request hits the API, roughly this happens:
- Auth middleware resolves
Authorization: Bearer ...to a user id. - Billing middleware checks the user’s quota and concurrency gates. If either is exceeded, return 402 or 429 without touching the render path.
- A job row is inserted into Postgres with
status = queued. - The response returns the
job_id; the HTTP connection closes. - A worker pool picks the job up, launches a Chromium page, sends the HTML, waits for network idle (if requested), calls
page.pdf(), uploads the bytes to object storage. - The job row is updated to
status = succeeded. - Your polling hits
/v1/jobs/:id, seessucceeded, follows with/download. - The download endpoint streams the bytes from object storage back to you.
Nothing exotic in there. The engineering that matters is in the details — how the worker pool handles crashed Chromium processes, how the object-storage path is SSE-encrypted, how quotas are race-safe under concurrent increments. At 21pdf we’ve written these components over time; competitors have written their own variations; the differences are real but granular.
Common pitfalls (and how to avoid them)
“My PDF is blank / missing data”
The request rendered faster than your data arrived. Fix: add wait_for_network_idle: true or pin to a selector (wait_for: '.ready'). If you control the page, add a window.__reportReady = true setter and have the API wait on it — most APIs accept a JS expression as a wait condition.
”My charts render as empty divs”
Most chart libraries paint to a canvas after DOM ready. You need network-idle plus a small additional delay (delay_ms: 300) to catch the canvas draw. Alternatively, many chart libraries have a server-side render mode — use that if possible.
”My fonts look wrong”
Chromium’s font fallback kicks in when your @font-face declaration points at a CDN that hasn’t loaded yet, or your font-family name doesn’t match. Check that:
@font-face srcURLs are reachable from the vendor’s infra (they usually are; corporate intranets break this)- network-idle is enabled (so the font fetch completes)
- you’re declaring exact
font-familynames, not category fallbacks
”Page breaks are in the wrong place”
You probably have a display: flex container that doesn’t break across pages. Chromium respects break-inside: avoid, break-before: page, break-after: page, and the legacy page-break-* family. Walk your HTML and insert breaks explicitly at the right boundaries.
”Quota errors at inopportune moments”
Check whether your API’s quota reset is calendar-aligned (the 1st of the month) or cycle-aligned (the day you subscribed). 21pdf resets on the subscription.charged webhook from our billing provider, so it’s cycle-aligned; some competitors are calendar-aligned. If you bill your customers monthly on the 1st and your vendor resets on the 15th, you’ll have a perpetually-confusing quota-accounting discrepancy.
”Concurrency limit hits during batch jobs”
Your plan’s concurrency limit was designed for interactive traffic. Batch jobs need to self-throttle — send N requests where N ≤ concurrency, wait for one to complete before sending the next. This is a client-side job queue; most teams write it in 50 lines and move on.
Self-host vs managed
Every evaluation eventually asks this question. The honest answer is volume-dependent:
| Daily PDFs | Recommendation |
|---|---|
| < 1,000 | Managed API. Pay the $20-$100/month and spend zero hours on infrastructure. |
| 1,000 - 10,000 | Managed API. The economics still favour it; you have bigger fish. |
| 10,000 - 100,000 | Flip a coin. If PDF rendering is central to your product and you have spare Kubernetes capacity, self-hosting is viable. Otherwise stay managed. |
| > 100,000 / day | Self-host, or negotiate a volume deal with a managed vendor. At this scale the 50-80% margin vendors take starts to matter. |
Self-hosting isn’t just “run Chromium in a container” — it’s:
- Keeping the Chromium binary patched (weekly-to-monthly for security CVEs)
- Managing the font catalogue (you’d be surprised how often missing fonts cause tickets)
- Running a worker pool with health checks, OOM handling, and process recycling (Chromium leaks memory over thousands of tabs)
- Implementing the same SSRF, quota, and concurrency gates you’d otherwise buy
- Operating object storage for output PDFs
- Monitoring render-time distributions and alerting on degradation
- Handling enforcement — what happens when a customer submits 10,000 parallel jobs
Each is tractable; the cumulative operational cost is a full-time engineer’s partial attention. If rendering PDFs is not your product, outsource it.
A quick note on SSRF
I mentioned SSRF above; it deserves a longer note because it’s the security issue most vendors get wrong.
Any API that accepts a url input is effectively letting customers make HTTP requests from inside your infrastructure. Without defences, they can:
- Read AWS/GCP metadata endpoints (
169.254.169.254) and exfiltrate IAM credentials - Probe internal services (your Postgres on
localhost:5432, your Redis, your admin panels) - Pivot to internal subnets that should be unreachable from the internet
The minimum viable defence is the two-layer check: block private IP ranges at the HTTP boundary, re-check inside Chromium. The harder cases are:
- DNS rebinding: Attacker-controlled DNS returns a public IP at boundary-check time, then a private IP when Chromium actually fetches. Defence: re-check inside the browser on every request.
- HTTP redirects: You validate
https://attacker.com/redirect-me, Chromium follows it tohttp://169.254.169.254/. Defence: validate the redirect target too, or disable auto-redirects and walk them yourself. - WebSocket / EventSource: Browser sub-requests don’t all go through
fetch. Your request interceptor has to cover WS, SSE, and dynamic imports.
The CVE archive is full of HTML-to-PDF APIs that got one of these wrong. If you care about SSRF (and you should, if you handle enterprise customers), ask the vendor for their specific mitigation strategy before you sign.
Pricing, briefly
Every vendor prices differently, but the market has settled into a narrow range for 10k PDFs/month:
| Vendor (approx) | 10k PDFs/month | Model |
|---|---|---|
| 21pdf | $29 | Flat subscription |
| PDFShift | $29 | Flat subscription |
| DocRaptor | $99 | Flat (Prince engine — premium) |
| API2PDF | ~$35 | Pay-per-PDF ~$0.004 |
| PDFCrowd | $29 | Flat subscription |
| PDFMonkey | $29 | Flat subscription |
All within 2-4× of each other. For most teams the decision isn’t price; it’s whether the specific features you need (SSRF hardening, @page support, network-idle, SLA) are ones you trust the vendor to have gotten right.
21pdf’s pricing: $0 Free (100 PDFs/mo) · $9 Starter (1,000) · $29 Pro (10,000) · $69 Business (50,000). Every paid plan includes every API feature; plans differ only on monthly throughput and concurrency. Pricing page has the full comparison.
Build on 21pdf
A precision HTML-to-PDF API with Chromium rendering, SSRF two-layer defence, async job model, and per-plan quotas. Free tier: 100 PDFs/month. No card required.
What to ask a vendor before you pick one
A checklist I’d actually use, in rough priority order:
- What Chromium version do you run, and how often do you patch it? “We’re on Chromium 128, patched within 7 days of upstream” is a good answer. Silence is a bad answer.
- How do you handle SSRF for URL inputs? Listen for “two layers” or “DNS rebinding” in the response. If you hear only “we filter private IPs”, that’s incomplete.
- Do you support CSS
@page/ paged-media? Yes is table stakes. - Can I wait for network idle / a specific selector / a JS expression? Network idle is table stakes; selector/JS is a plus.
- What’s your async model — sync-only, async-only, or both? Both is nice; async-only (what 21pdf does) is fine for production but slightly noisier for dev.
- What are the quota and concurrency limits, and what error codes do they return? Distinct 402 vs 429 is the correct answer.
- What happens at the 10,001st PDF on a 10k plan? Hard block, soft throttle, or overage billing. All are viable; you just need to know.
- Is billing cycle-aligned or calendar-aligned? Not a dealbreaker, just needs to match your accounting.
- What’s your uptime over the last 12 months? Ask for a link to a public status page, not a marketing number.
- Can I self-serve an API key right now, or do I need a sales call? Self-serve is better. Every vendor on 21pdf’s list above is self-serve.
If you get clean answers to all 10, the vendor has thought about the product. If you get marketing deflection on half of them, move on.
Closing
An HTML-to-PDF API is, in 2026, a fairly commodity thing. Any vendor with a recent Chromium, sane SSRF handling, and a reasonable async model will render your invoices correctly. The differences that matter for your specific use case are narrow — and after five years of working on document infrastructure, I’m convinced that most teams overthink this decision.
Pick a vendor whose pricing page is a table, whose docs work, and whose error codes distinguish quota from concurrency. Integrate it in an afternoon. Come back in a month and tighten the parts that hurt — usually wait conditions and font fallbacks. Don’t switch until you have a concrete reason.
If you want to try 21pdf, the free tier is 100 PDFs/month with no card. It’ll take you about 10 minutes to get your first PDF rendered.
— 21pdf Engineering