Quickstart
One call, a PDF. Run it here first, with no key and no account, then take a key and run it from your own machine. After that, the same document stored as a template so every later render sends data instead of markup.
1Run it now, without a key
This is the call in step 3, made from this page against a key of ours. It renders one fixed document, so nothing you type reaches it, and what comes back is the API talking rather than a recording of it.
No key, no account. 5 runs an hour from one connection.
2Get a key
Ask for a key and one normally arrives within a day. Keys are issued by hand while we are in early access, so there is no signup and no card, and the free tier is 100 pages a month. The key is shown once. Free-plan output carries a watermark and renders with JavaScript disabled, which is why the response below says watermarked: true; a paid plan removes both.
Keys look like pp_live_…. A pp_test_… key runs the whole pipeline, watermarks the output and charges nothing. Use it while you are iterating.
export PAPERPONY_API_KEY=pp_live_your_key_hereA live key must never be shipped to a browser. The API allows any origin because it is a server-side API behind a bearer token, and that is a convenience for tooling, not permission to call it from a page. More on this.
3Render your first PDF
Send HTML and the values to fill it with. The response is the finished job.
curl https://api.paperpony.dev/v1/pdf/render \
-H "Authorization: Bearer $PAPERPONY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Hello from PaperPony</h1><p>Rendered {{when}}.</p>",
"data": { "when": "just now" }
}'import { PaperPony } from 'paperpony';
const pony = new PaperPony({ apiKey: process.env.PAPERPONY_API_KEY });
const job = await pony.pdf.render({
html: '<h1>Hello from PaperPony</h1><p>Rendered {{when}}.</p>',
data: { when: 'just now' },
});
console.log(job.output_url);import os
import requests
response = requests.post(
"https://api.paperpony.dev/v1/pdf/render",
headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
json={
"html": "<h1>Hello from PaperPony</h1><p>Rendered {{when}}.</p>",
"data": {"when": "just now"},
},
)
response.raise_for_status()
print(response.json()["output_url"])You get back:
{
"id": "job_01JZ3M8Q0000000000000000CD",
"status": "succeeded",
"product": "pdf",
"mode": "sync",
"template_id": null,
"output_url": "https://<bucket>.<account>.r2.cloudflarestorage.com/o/...?X-Amz-Signature=...",
"output_bytes": 48213,
"output_expires_at": "2026-07-27T10:00:03Z",
"page_count": 1,
"credits_charged": 1,
"credits_remaining": 99,
"watermarked": true,
"error": null,
"created_at": "2026-07-26T10:00:00Z",
"started_at": "2026-07-26T10:00:00Z",
"finished_at": "2026-07-26T10:00:03Z"
}Open output_url. That is your PDF. The elided part of the path is the object key, o/{job_id}/{filename}, and the filename is whatever options.filename asked for. The bucket is private and the link is signed, so it stops working when it expires: 24 hours on the free tier, up to seven days on the others, which is as far ahead as SigV4 will sign. When one has expired, read GET /v1/jobs/:id and it signs a fresh link from the stored object key. Keep the job id and you never need the URL.
4Store it as a template
Sending the same markup on every call is wasteful and makes the document hard to change. Store it once and it gets an id.
curl https://api.paperpony.dev/v1/templates \
-H "Authorization: Bearer $PAPERPONY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Invoice",
"product": "pdf",
"source": "<h1>Invoice {{number}}</h1><p>Due {{formatDate due_on}}</p><p>{{formatCurrency total currency=currency}}</p>"
}'const template = await pony.templates.create({
name: 'Invoice',
product: 'pdf',
source:
'<h1>Invoice {{number}}</h1>' +
'<p>Due {{formatDate due_on}}</p>' +
'<p>{{formatCurrency total currency=currency}}</p>',
});
console.log(template.id); // tpl_...template = requests.post(
"https://api.paperpony.dev/v1/templates",
headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
json={
"name": "Invoice",
"product": "pdf",
"source": "<h1>Invoice {{number}}</h1>"
"<p>Due {{formatDate due_on}}</p>"
"<p>{{formatCurrency total currency=currency}}</p>",
},
).json()
print(template["id"])The source is Handlebars. formatDate and formatCurrency are two of the five helpers available. See the template guide.
5Render it with data
curl https://api.paperpony.dev/v1/pdf/render \
-H "Authorization: Bearer $PAPERPONY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tpl_01JZ3M8Q0000000000000000AB",
"data": {
"number": "INV-0001",
"due_on": "2026-08-25",
"total": 4308,
"currency": "USD"
}
}'const job = await pony.pdf.render({
template_id: template.id,
data: { number: 'INV-0001', due_on: '2026-08-25', total: 4308, currency: 'USD' },
});
console.log(job.output_url, job.page_count, job.credits_remaining);job = requests.post(
"https://api.paperpony.dev/v1/pdf/render",
headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
json={
"template_id": template["id"],
"data": {
"number": "INV-0001",
"due_on": "2026-08-25",
"total": 4308,
"currency": "USD",
},
},
).json()
print(job["output_url"], job["page_count"], job["credits_remaining"])That is the shape of every render from here on: an id and a payload. One credit per page, minimum one per job, and credits_remaining comes back with every success.
6Know what to expect when it is slow
A render that finishes within twenty seconds answers 200 with the finished job. One that does not answers 202 with the same object still running, so poll GET /v1/jobs/{id} until status is terminal. Pass "async": true to get the 202 immediately.
Send an Idempotency-Key header on renders you might retry. The same key on the same account within 24 hours returns the original job and charges nothing.
Next
- Template guide covers helpers, page breaks, headers and footers, and fonts.
- Recipes are complete templates for invoices, receipts, certificates, reports and labels.
- Full reference for
POST /v1/pdf/render. - Every error code, with causes and fixes.