API reference
Base URL https://api.pagenull.dev. All requests are JSON over HTTPS and authenticate with Authorization: Bearer <key> (or X-API-Key). Successful renders return the file itself.
POST /v1/pdf
Render HTML or a URL to PDF. Returns 200 application/pdf.
Source and loading
htmlstring- HTML to render. Up to 5 MB. Provide exactly one of html or url.
urlstring- Public http(s) URL to load. Private, loopback, link-local and metadata addresses are refused.
javascriptboolean- default true
- Run page scripts. Turn off for untrusted HTML.
allowNetworkboolean- default true
- Let the page load images, fonts and CSS from public hosts. false blocks every request except the page itself.
waitUntil"load" | "domcontentloaded" | "networkidle"- default "load"
- When the page counts as loaded.
waitForstring- CSS selector to wait for before capturing, e.g. "#chart[data-ready]".
timeoutMsinteger- default 30000
- Give up after this long (1000–60000).
emulateMedia"print" | "screen"- default pdf: "print", screenshot: "screen"
- Which CSS media type applies.
Page
format"A4" | "Letter" | "Legal" | "A3" | "A5" | "Tabloid"- default "A4"
- Paper size.
width / heightlength- Custom page size, e.g. "80mm" × "200mm" for thermal receipts. Set both; overrides format.
landscapeboolean- default false
- Rotate the page.
margin{ top, right, bottom, left }- default 0
- Each a length with a unit: "12mm", "0.5in", "1cm", "24px".
printBackgroundboolean- default true
- Print background colours and images.
headerHtml / footerHtmlstring- HTML repeated on every page. Use <span class="pageNumber"> and <span class="totalPages">. Set a font-size (e.g. 9px); styles from the page don't apply. Needs a matching margin.
scalenumber- default 1
- 0.1–2.
pageRangesstring- default all
- e.g. "1-3, 5".
preferCssPageSizeboolean- default false
- Let a CSS @page size rule win over format.
Delivery
deliverTostring (https URL)- A presigned PUT URL in your own storage (S3, R2, GCS, Azure, MinIO). We upload the output there, answer 201 with its size and SHA-256, and keep nothing. Redirects aren't followed; private addresses are refused.
testboolean- default false
- Test render: watermarked, free, doesn't count against your quota.
filenamestring- default "document"
- Name used in the Content-Disposition header.
{
"html": "<h1>Report</h1>…",
"format": "A4",
"margin": {
"top": "20mm",
"bottom": "18mm",
"left": "15mm",
"right": "15mm"
},
"footerHtml": "<div style=\"font-size:9px;width:100%;text-align:center\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>",
"waitFor": "#chart[data-ready]"
}POST /v1/screenshot
Same source, loading and delivery options as /v1/pdf, plus:
type"png" | "jpeg"- default "png"
- Image format.
qualityinteger- default 85
- JPEG quality, 1–100.
width / heightinteger- default 1280 / 800
- Viewport size in CSS pixels (100–4000).
fullPageboolean- default false
- Capture the whole scrollable page.
deviceScaleFactornumber- default 1
- 1–3. Use 2 for retina-sharp images.
Templates
POST /v1/templates/{id}/render
Body: { "data": { … }, "options": { … } }. data is merged into the template (Mustache syntax); options overrides the template’s saved page options for this call. Delivery fields (deliverTo, test, filename) go at the top level. Built-in ids: invoice, receipt, certificate, report.
GET /v1/templates
Lists your saved templates (id, name, dates) and the built-ins. Doesn’t count as a render.
POST /v1/templates
Save a template: { "name": "Invoice", "html": "…", "sampleData": { … }, "options": { "format": "A4" } }. Returns 201 { "id": "tpl_…" }. HTML and sample data are encrypted at rest.
POST /v1/invoices (Factur-X / ZUGFeRD)
An EU e-invoice in one call: a PDF/A-3b file with the matching EN 16931 XML embedded as factur-x.xml. People read the PDF; accounting software reads the XML. You send the invoice as structured data, and we calculate the line totals, the VAT breakdown and the totals in exact decimals, so the printed numbers and the XML always agree. Counts as one render. The Factur-X guide walks through it.
invoiceobject- The invoice. Fields below.
profile"EN16931" | "BASIC" | "MINIMUM"- default "EN16931"
- Factur-X profile. EN16931 carries the full standard and is what most buyers expect. BASIC drops some optional fields. MINIMUM carries only the header and totals and doesn't count as an e-invoice in Germany.
languagestring- default "en-GB"
- Locale for amounts and dates on the printed side, e.g. "de-DE", "fr-FR".
templatestring- default "invoice"
- Printed layout: the built-in invoice template or one of your saved template ids. It receives the computed values, already formatted.
optionsobject- default {}
- Page options as for /v1/pdf (format, margin, pageNumbers …).
deliverTo / test / filename- As for /v1/pdf. filename defaults to the invoice number.
The invoice object
type"invoice" | "credit_note"- default "invoice"
- A credit note lists the credited amounts as positive numbers, and can name precedingInvoice.
numberstring- Your invoice number.
issueDate / dueDate / supplyDateYYYY-MM-DD- default supplyDate = issueDate
- Dates. supplyDate is the date of supply.
currencyISO 4217- default "EUR"
buyerReference / orderReferencestring- The buyer's reference (e.g. their PO number) and order number.
seller / buyer{ name, address, vatId, legalId, email }- address is { line1, line2, city, postcode, country } with a 2-letter country code. vatId has its country prefix, e.g. "DE123456789". The seller's is required unless every line is O.
lines[]{ description, detail, quantity, unit, unitPrice, vatCategory, vatRate }- default unit: "C62"
- Net unit price before VAT, up to 4 decimals. unit is a UN/ECE Rec 20 code: C62 (one), HUR (hour), DAY, MON, KGM …
payment{ iban, bic, accountName, reference, terms }- SEPA credit transfer details. reference defaults to the invoice number.
paidAmountnumber- Already paid; the amount due is the total minus this.
vatExemptionReasonstring- default per category
- Printed and embedded for E, AE, K, G and O lines. The default is the category name, e.g. "Reverse charge".
notestring- Free text on the invoice.
VAT categories (vatCategory): S standard rate (the default), Z zero rated, E exempt, AE reverse charge, K intra-community supply, G export outside the EU, O not subject to VAT. Every category except S takes vatRate: 0. Rules the standard sets, such as needing the buyer’s VAT number for reverse charge, are checked before rendering and answer 422 invalid_invoice with the field named.
{
"invoice": {
"number": "INV-2026-0142",
"issueDate": "2026-11-03",
"dueDate": "2026-11-17",
"seller": {
"name": "Northwind Studio EOOD",
"address": {
"line1": "12 Example Street",
"city": "Sofia",
"postcode": "1000",
"country": "BG"
},
"vatId": "BG123456789"
},
"buyer": {
"name": "Acme Robotics GmbH",
"address": {
"line1": "Musterstraße 1",
"city": "Berlin",
"postcode": "10115",
"country": "DE"
},
"vatId": "DE123456789"
},
"lines": [
{
"description": "API integration, October",
"quantity": 12,
"unit": "HUR",
"unitPrice": "85.00",
"vatCategory": "AE",
"vatRate": 0
}
],
"payment": {
"iban": "BG80BNBG96611020345678"
}
},
"profile": "EN16931"
}The response is the PDF, with pagenull-einvoice: factur-x; profile=EN16931; pdfa=3b next to the usual headers. Test renders ("test": true) are watermarked and their XML carries the note TEST RENDER. NOT A VALID INVOICE., so never send one to a customer.
Not supported yet: document-level discounts and surcharges, payment means other than SEPA credit transfer, XRechnung, and UBL. The built-in layout prints its labels in English; numbers and dates follow language.
Delivery to your storage
pagenull never stores output, and there is no option to make it. When the file needs to land somewhere other than the response (async jobs, no-code tools, very large files), create a presigned PUT URL in your own bucket and pass it as deliverTo. The response is 201 with { "delivered": true, "bytes", "sha256", "contentType", "pages" } plus the usual headers and receipt.
GET /v1/usage
Returns { period, plan, used, limit, remaining, ratePerMinute } for the current month.
Response headers
Every render response carries these. They are explained on trust.
pagenull-retention: none: nothing from the request or output was storedpagenull-region: where it was renderedpagenull-render-id: the id we log; quote it to supportpagenull-renderer: digest of the renderer imagepagenull-receipt: Ed25519-signed receipt, verifiable at /verifypagenull-pagesand, when something looked wrong,pagenull-warningsX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(seconds)X-Quota-Limit,X-Quota-Usedon metered endpointsX-Render-Ms: time spent in Chromium
Errors
Every error has the same shape, and failed renders never count against your quota:
{
"error": {
"code": "blocked_url",
"message": "URL blocked: address is not public",
"docs": "/docs/api#errors"
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The body isn't valid JSON or an option is wrong. The message names the field. |
| 401 | unauthorized | Missing, unknown or revoked API key. |
| 403 | forbidden | Your plan doesn't include this (e.g. a saved-template limit). |
| 404 | not_found | No such template on this account. |
| 409 | idempotency_in_progress | A request with the same Idempotency-Key is still running. Retry after Retry-After seconds. |
| 409 | idempotency_replay_limit | This Idempotency-Key has been retried 5 times. Use a new key. |
| 413 | payload_too_large | Body larger than the limit. |
| 413 | output_too_large | The output is over 50 MB or 1,000 pages. |
| 422 | idempotency_mismatch | The Idempotency-Key was already used for a different request. |
| 422 | invalid_invoice | /v1/invoices: the invoice breaks an EN 16931 rule (e.g. reverse charge without the buyer's VAT number). The message names the field. |
| 422 | blocked_url | The URL, or a resource the page tried to load, points at a non-public address or a disallowed scheme. |
| 429 | rate_limited | Too many requests this minute, for this key or for the account. Wait for Retry-After seconds. |
| 429 | quota_exceeded | Monthly renders used up. Resets on the 1st (UTC). |
| 502 | render_failed | The URL couldn't be loaded. |
| 500 | internal_error | Something broke on our side. Not charged. Retry, and quote pagenull-render-id if it keeps happening. |
| 503 | busy | All renderers busy. Retry after Retry-After seconds. |
| 503 | renderer_unavailable | The renderer restarted or couldn't be reached during the request. Not charged. Retry after Retry-After seconds. |
| 504 | timeout | The page didn't finish in timeoutMs, or waitFor never matched. |
Retries and idempotency
Send an Idempotency-Key header (any unique string up to 255 characters, such as your order id) on render requests. If the connection drops and you retry with the same key and the same body within 24 hours, the retry is rendered again and not charged again. The response carries idempotent-replayed: true.
- We can’t return a stored copy of the first PDF, because there isn’t one. A retry renders again, so its receipt has a new render id.
- The same key with a different body is refused (
idempotency_mismatch). A key still running answersidempotency_in_progress. - A failed first attempt isn’t remembered: the retry is an ordinary render.
- Each key gets up to 5 free re-renders.
- What we keep: a keyed hash of the key and one of the request, for 24 hours. Never the key you sent and never the body.
Safe to retry: 429 (after Retry-After), 503 (after Retry-After), 504 and network errors. Don’t retry 400, 401, 413 or 422 without changing the request.
Limits
| Plan | Renders / month | Requests / minute, per key | Saved templates |
|---|---|---|---|
| Free | 100 | 10 | Unlimited |
| Starter | 3,000 | 60 | Unlimited |
| Growth | 15,000 | 120 | Unlimited |
| Scale | 50,000 | 240 | Unlimited |
| Volume | 200,000 | 480 | Unlimited |
- HTML body: 5 MB. Header and footer: 64 KB each.
- Render time: up to 60 s. Output: up to 50 MB and 1,000 pages.
- Rate limits apply to each key, so one runaway key can’t block your others. All keys on an account together get up to 3× the per-key rate.
- Requests wait in a short queue when every renderer is busy; past that you get
503 busywithRetry-After. - Each render runs in a fresh browser context: no cookies, cache or storage carry over between requests.
- WebSockets, service workers, downloads, pop-ups and dialogs are blocked.