v1 Chromium-only rendering · async by default

HTML
in, PDF
one POST, one job IDout.

An honest HTML-to-PDF rendering API. Send HTML or a URL to POST /v1/convert, poll the job, download the PDF. Chromium under the hood — no legacy wkhtmltopdf path, no six-language SDK surface we don't actually ship.

Free tier included Bearer & per-tenant API keys SSRF-blocked at two layers
01 / Live shape

One endpoint. One response. Keep going.

The whole API is POST /v1/convert plus two job endpoints. No webhooks yet, no direct-to-S3 yet — just a bounded worker pool and an object store the client picks up from.

curl
node (fetch)
python
go
POST https://login.21pdf.com/v1/convert
# Convert a URL to a PDF — async job model
curl -X POST https://login.21pdf.com/v1/convert \
  -H "Authorization: Bearer $QPDF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/invoice/8412",
    "options": {
      "page_size": "A4",
      "orientation": "portrait",
      "margin_top": "20mm",
      "wait_for_network_idle": true
    }
  }'
# → { "job_id": "5f1c3e2a-...", "status": "pending" }
# Then: GET /v1/jobs/{job_id}/download once status == "completed"
// No SDK yet — the raw HTTP API is the surface.
const res = await fetch("https://login.21pdf.com/v1/convert", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.QPDF_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com/invoice/8412",
    options: { page_size: "A4", wait_for_network_idle: true },
  }),
});
const { job_id } = await res.json();
# Standard library requests — no pip install needed from us.
import os, requests

r = requests.post(
    "https://login.21pdf.com/v1/convert",
    headers={"Authorization": f"Bearer {os.environ['QPDF_KEY']}"},
    json={
        "url": "https://example.com/invoice/8412",
        "options": {"page_size": "A4", "wait_for_network_idle": True},
    },
)
job_id = r.json()["job_id"]
// Plain net/http. No qpdf-go package exists; we'd rather you see that.
body, _ := json.Marshal(map[string]any{
    "url": "https://example.com/invoice/8412",
    "options": map[string]any{
        "page_size": "A4",
        "wait_for_network_idle": true,
    },
})
req, _ := http.NewRequest("POST", "https://login.21pdf.com/v1/convert", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("QPDF_KEY"))
Request · json ● 202 Accepted
{
  "url": "https://example.com/invoice/8412",
  "options": {
    "page_size": "A4",
    "orientation": "portrait",
    "margin_top": "20mm",
    "margin_bottom": "15mm",
    "wait_for_network_idle": true
  }
}

// Response
{
  "job_id": "5f1c3e2a-9b4d-4c8a-9e2f-1a2b3c4d5e6f",
  "status": "pending",
  "message": "enqueued"
}
After polling · rendered GET /v1/jobs/{id}/download
EXAMPLE CO. INV-8412 · 2026-04-24

Invoice #8412

Bill to: Northwind Trading Co. · 221 Baker St, Portland OR

ItemQtyTotal
API calls — Apr 2026412,900₹ 28,903
Overage renders8,412₹ 2,104
Priority support1₹ 5,000
Subtotal · ₹ 36,007
GST (18%) · ₹ 6,481
Total due · ₹ 42,488
login.21pdf.com/jobs/5f1c3e2a 1 / 1
02 / What's in the box

The things we actually ship.

No roadmap tiles. Everything below is live on https://login.21pdf.com/v1 today. Things that aren't shipped are listed as "Roadmap" in the docs, not promoted here.

/ 01

HTML & URL input

Send a fully-formed HTML document or a URL. Chromium loads and prints it with the PDFOptions you pass.

/ 02

Page options

page_size, orientation, four-sided margins, and wait_for_network_idle for SPA-heavy sources.

/ 03

Async by default

Every submission returns a job_id. Poll status, download when completed. No long-held HTTP connections.

/ 04

Bearer & API keys

Per-tenant API keys with rotation. Bearer tokens for your dashboard sessions. Keys never leave the dashboard in plaintext.

/ 05

Per-plan quotas

Razorpay-backed subscriptions. Quota + concurrency are enforced server-side; 402 returns when you hit the cap.

/ 06

Grace periods

Card declines drop you to a 7-day grace window instead of immediate cut-off. Dunning emails hit SMTP on day 1, 3, 5.

/ 07

SSRF-blocked

RFC1918, link-local, and loopback targets are refused — at the HTTP boundary and inside Chromium's request interceptor.

/ 08

Renter-safe Chromium

Hardened launch flags, supervised relaunch on crash, renders short-circuited to a pool so a bad page can't starve the next.

/ 09

Orphan sweeper

Rendered files auto-expire after PDF_RETENTION_DAYS (default 7). No stale PDFs accumulating in object storage forever.

See the full endpoint doc →
Async job model

Submit, poll, download. No 30‑second hold.

Every convert returns a job ID immediately. The worker pool renders in the background and stores the output; your client polls /v1/jobs/{id} and fetches /download when status == "completed". Perfect for queue-backed pipelines; no HTTP timeout-tuning.

{
  "job_id": "5f1c3e2a-...",
  "status": "running",
  "progress": 0.7
}
SSRF policy

Private-IP exfiltration fails twice.

When you pass a url, we reject RFC 1918, link-local and loopback targets at the HTTP handler. Then the Chromium request interceptor re-runs the same allowlist on every subresource and redirect hop — so a 302 that lands on 169.254.169.254 still fails closed.

Both layers must fail-open for a leak to land. Neither does.

Ship honest PDFs
by the end of this commit.

$ curl -X POST login.21pdf.com/v1/convert -H "Authorization: Bearer $KEY" -d '{"url":"…"}'
Get an API key → Read /v1/convert docs