Generate a PDF with one POST request
Send HTML, a URL or a template, get a finished PDF back. No SDK required — any HTTP client works, and the OpenAPI 3.1 spec generates one for you. The free tier covers 50 documents a month.
Quick start
Create a free account, confirm your email, and your key is waiting in the dashboard. Then one request:
# 1. create a free account and copy your key from the dashboard
# https://pdfgeny.com/account/signup/
# 2. render a document
curl -X POST https://pdfgeny.com/api/v1/render \
-H "Authorization: Bearer $PDFGENY_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>Invoice 001</h1>"}' \
--output invoice.pdf
Authentication
Every request carries your key in a header. X-API-Key works as well if a bearer token is awkward in your client. Keys are shown once at creation and stored hashed — if you lose one, revoke it and create another in the dashboard.
Authorization: Bearer pg_your_key_here
POST /api/v1/render — synchronous
Send exactly one of html, url or template. The response is the PDF itself (Content-Type: application/pdf), or JSON with a stored document link if you pass "response": "json". Waits up to 60 s; for slow pages or bulk work use jobs.
| Field | Type | Default | Notes |
|---|---|---|---|
html | string | — | Markup to render. Up to 2 MB. |
url | string | — | Public http(s) page. Private networks are refused. |
template | string | — | Template id from GET /v1/templates, e.g. invoice. Pair with data. |
data | object | {} | Field values for the template; missing fields use the template's examples. |
format | string | A4 | A3, A4, A5, Letter, Legal, Tabloid. |
landscape | bool | false | |
margin | string | object | 12mm | One CSS length, or {"top","right","bottom","left"}. |
scale | number | 1.0 | 0.1 – 2. |
print_background | bool | true | |
header_html / footer_html | string | — | Repeated on every page. and are replaced. |
wait_for | string | — | CSS selector to wait for, or milliseconds (max 15000). Useful for charts. |
engine | string | chromium | weasy = WeasyPrint: no JavaScript, crisp output for table-heavy documents, cheaper. |
pdfa | bool | false | Convert to PDF/A-2b for archiving. |
width + height | string | — | Exact page size with units ("1200px", "210mm"). Overrides format — useful for social images, labels and tickets. |
title | string | — | PDF document title metadata. |
filename | string | document.pdf | Content-Disposition and stored name. |
store | bool | false | Keep the file 7 days and make it downloadable by signed URL. |
response | string | binary | json → {document_id, document_url, size_bytes, duration_ms, expires}. |
Response headers: X-Render-Duration-Ms, X-Quota-Remaining.
POST /api/v1/jobs — asynchronous
Same body as /render plus an optional webhook_url (https). Returns 202 immediately with a job id; poll GET /api/v1/jobs/{id} or wait for the webhook. Finished jobs carry document_url, valid for 7 days.
curl -X POST https://pdfgeny.com/api/v1/jobs \
-H "Authorization: Bearer $PDFGENY_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/report","webhook_url":"https://your.app/hooks/pdf"}'
{"id":"27015855ad29…","status":"queued","created":"2026-08-31T11:20:30Z"}
Webhook delivery
We POST {"event":"job.finished","job":{…}} with header X-PDFGeny-Signature: sha256=<HMAC-SHA256 of the raw body>, keyed with your webhook secret (GET /api/v1/webhook-secret; POST rotates it). Failed deliveries retry 5 times over ~3 hours. Verify like this:
import hmac, hashlib
expected = "sha256=" + hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers["X-PDFGeny-Signature"])
POST /api/v1/batch — many documents
Up to 100 documents in one call. Top-level options apply to every item; each item may override them. Track progress at GET /api/v1/batch/{batch_id}.
{"format":"A5",
"documents":[
{"template":"receipt","data":{"amount":"12.00","payer":"Ada"}},
{"template":"receipt","data":{"amount":"7.50","payer":"Grace"}},
{"html":"<h1>Custom</h1>"}
]}
GET /api/v1/templates
The catalog with every field a template accepts and an example value. Render one with {"template":"invoice","data":{…}} — page size and margins come from the template unless you override them.
curl https://pdfgeny.com/api/v1/templates
{"templates":[{"id":"invoice","name":"Invoice","fields":[{"name":"client","label":"Bill to","example":"Acme Corporation"},…]},…]}
Examples
Python
import requests
pdf = requests.post(
"https://pdfgeny.com/api/v1/render",
headers={"Authorization": f"Bearer {KEY}"},
json={"html": "<h1>Invoice 001</h1>", "format": "A4"},
timeout=60,
)
open("invoice.pdf", "wb").write(pdf.content)
Node.js
const res = await fetch("https://pdfgeny.com/api/v1/render", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com/report", format: "Letter" }),
});
await writeFile("report.pdf", Buffer.from(await res.arrayBuffer()));
PHP
$ch = curl_init("https://pdfgeny.com/api/v1/render");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $key", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["html" => "<h1>Invoice</h1>"]),
]);
file_put_contents("invoice.pdf", curl_exec($ch));
Go
body, _ := json.Marshal(map[string]any{"html": "<h1>Invoice</h1>"})
req, _ := http.NewRequest("POST", "https://pdfgeny.com/api/v1/render", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
Quotas and errors
Check your remaining balance at any time:
curl https://pdfgeny.com/api/v1/usage -H "Authorization: Bearer $PDFGENY_KEY"
{"plan":"free","quota":50,"used":3,"remaining":47,"watermark":true,"resets":"2026-10-01"}
Errors share one shape: {"error": {"code": "quota_exceeded", "message": "…", "docs": "…"}}. Codes: missing_api_key, invalid_api_key, invalid_input, payload_too_large, quota_exceeded, rate_limited, too_many_concurrent, render_failed, not_found, invalid_signature.
Rate limits
Per key: Free 1 request/s and 1 concurrent render; Starter 5/s and 3; Growth 10/s and 5; Scale 20/s and 10. Over the limit you get 429 with retry_after. Queued jobs are not subject to the concurrency limit — that is what they are for.
| Status | Meaning |
|---|---|
401 | Key missing, unknown, revoked, or the account email is not confirmed yet. |
400 | Malformed body, or neither/both of html and url. |
413 | HTML payload above 2 MB. |
422 | The page failed to render — bad markup, or a URL that never loaded. |
429 | Quota reached, rate limit, or too many concurrent renders — see error.code. |
403 | Document link signature invalid or expired. |
404 | Job, batch or document not found (or expired). |
Documents on the free plan carry a small footer line. Any paid plan removes it.