Skip to content

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.
Example body
{
  "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.

Example body
{
  "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 stored
  • pagenull-region: where it was rendered
  • pagenull-render-id: the id we log; quote it to support
  • pagenull-renderer: digest of the renderer image
  • pagenull-receipt: Ed25519-signed receipt, verifiable at /verify
  • pagenull-pages and, when something looked wrong, pagenull-warnings
  • X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (seconds)
  • X-Quota-Limit, X-Quota-Used on metered endpoints
  • X-Render-Ms: time spent in Chromium

Errors

Every error has the same shape, and failed renders never count against your quota:

Error body
{
  "error": {
    "code": "blocked_url",
    "message": "URL blocked: address is not public",
    "docs": "/docs/api#errors"
  }
}
StatusCodeMeaning
400invalid_requestThe body isn't valid JSON or an option is wrong. The message names the field.
401unauthorizedMissing, unknown or revoked API key.
403forbiddenYour plan doesn't include this (e.g. a saved-template limit).
404not_foundNo such template on this account.
409idempotency_in_progressA request with the same Idempotency-Key is still running. Retry after Retry-After seconds.
409idempotency_replay_limitThis Idempotency-Key has been retried 5 times. Use a new key.
413payload_too_largeBody larger than the limit.
413output_too_largeThe output is over 50 MB or 1,000 pages.
422idempotency_mismatchThe Idempotency-Key was already used for a different request.
422invalid_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.
422blocked_urlThe URL, or a resource the page tried to load, points at a non-public address or a disallowed scheme.
429rate_limitedToo many requests this minute, for this key or for the account. Wait for Retry-After seconds.
429quota_exceededMonthly renders used up. Resets on the 1st (UTC).
502render_failedThe URL couldn't be loaded.
500internal_errorSomething broke on our side. Not charged. Retry, and quote pagenull-render-id if it keeps happening.
503busyAll renderers busy. Retry after Retry-After seconds.
503renderer_unavailableThe renderer restarted or couldn't be reached during the request. Not charged. Retry after Retry-After seconds.
504timeoutThe 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 answers idempotency_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

PlanRenders / monthRequests / minute, per keySaved templates
Free10010Unlimited
Starter3,00060Unlimited
Growth15,000120Unlimited
Scale50,000240Unlimited
Volume200,000480Unlimited
  • 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 busy with Retry-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.