Docs
Base URL: https://api.pagepenny.com. JSON in, JSON or a file out. Every error has the same shape.
Quickstart
Get a free key, then render the sample invoice:
curl https://api.pagepenny.com/v1/render \
-H "Authorization: Bearer pp_live_..." \
-H "Content-Type: application/json" \
-d '{"template_id": "tpl_invoice_classic",
"data": {"seller": {"name": "Northwind"},
"customer": {"name": "Acme Corp"},
"invoice": {"number": "INV-1", "date": "2026-10-15"},
"items": [{"description": "Design", "quantity": 1, "unit_price": 2400}]}}'The response has a download link that works for 24 hours by default:
{
"id": "rnd_01m51...",
"status": "succeeded",
"format": "pdf",
"pages": 1,
"bytes": 111340,
"duration_ms": 312,
"warnings": [],
"url": "https://api.pagepenny.com/f/rnd_01m51...?exp=...&sig=...",
"expires_at": "2026-10-17T03:08:00+00:00"
}Authentication
Send your key as Authorization: Bearer pp_live_.... Keys are shown once when created. Make and revoke them in your dashboard. Keep them on your server, not in browser code.
POST /v1/render
Send exactly one of template_id or html. Everything else is optional.
| Field | What it does |
|---|---|
template_id | A starter template, e.g. tpl_invoice_classic. |
html | Your own HTML. {{ placeholders }}, loops and filters work (Jinja). Up to 2 MB. |
data | The JSON your template uses. Up to 1 MB. |
format | pdf, png, jpeg or webp. Defaults to the template's format, or pdf. |
pdf | { page_size: Letter|A4|..., landscape, margin: "12mm", scale } |
image | { width, height, scale, quality, full_page }. Up to 4096 px a side. |
output | url (default), binary (the raw file, up to 4 MB), or base64. |
async | true queues the render. Use it for long documents (up to 500 pages). |
webhook_url | With async: an https URL we POST to when the render finishes. |
expires_in | Download link lifetime in seconds. Default 86400, max 604800. |
Templates can use {{ amount | money('USD') }}, {{ when | date('medium') }} and {{ x | number(2) }}. Missing values render blank and come back as a missing_variable warning. JavaScript does not run. Images, fonts and stylesheets can load from public https URLs.
Send an Idempotency-Key header to retry safely: the same key and body returns the original result and is charged once.
Templates
GET /v1/templates lists them. GET /v1/templates/{id} returns sample data, which is the easiest way to see what each one expects. Starters: tpl_invoice_classic, tpl_invoice_modern, tpl_receipt_80mm, tpl_quote, tpl_report_paged, tpl_certificate, tpl_og_basic, tpl_og_blog, tpl_social_square.
Async and webhooks
With "async": true you get 202 and an id right away. Poll GET /v1/renders/{id} or pass webhook_url. Webhooks are signed with your account's secret (in the dashboard):
X-PagePenny-Signature: t=1760580000,v1=<hex HMAC-SHA256 of "t.<raw body>">
# Python
import hmac, hashlib
def valid(secret, header, body: bytes):
t, v1 = (p.split("=", 1)[1] for p in header.split(","))
mac = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, v1)n8n, Make, Zapier
Use each tool's HTTP request module: method POST, URL https://api.pagepenny.com/v1/render, header Authorization: Bearer pp_live_..., JSON body as in the quickstart. To attach the PDF to an email or upload it somewhere, set "output": "binary" and have the module return a file. To just store a link, keep the default and use url from the response.
MCP for AI agents
PagePenny is a remote MCP server at https://api.pagepenny.com/mcp/. Tools: list_templates, get_template, render, get_render. Add it to Claude Code:
claude mcp add --transport http pagepenny https://api.pagepenny.com/mcp/ \
--header "Authorization: Bearer pp_live_..."Then ask for a document in plain words, e.g. "make an invoice for Acme for 3 hours of design at $90".
Pay per call (coming soon)
Not live yet. When it launches, agents with a USDC wallet on Base will be able to call POST /v1/render with no key. The first call returns 402 with payment requirements; the agent signs a $0.01 payment and retries with an X-PAYMENT header. You are charged only after the file renders.
Errors and limits
{"error": {"code": "quota_exceeded", "message": "monthly limit of 100 renders reached", "details": {...}}}401 unauthorized | Missing or revoked key. |
404 template_not_found | No such template id. |
409 idempotency_conflict | Same Idempotency-Key, different body. |
413 too_many_pages / input_too_large | Over 50 pages in a sync render (use async), or input over the limit. |
422 invalid_request / template_error | Bad body, or the template failed (line number included). |
429 rate_limited / quota_exceeded | Too fast (see Retry-After), or the monthly limit or cap is reached. |
503 capacity | Busy, retry after a couple of seconds. Not charged. |
504 render_timeout | Took over 20s. Retry with async. |
Limits: 10 requests a minute on Free, 120 on paid. Files are kept 7 days; request data is never stored. Renders that fail because of a problem on our side are not charged.