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
Sign in with your email address, follow the link we send, and create a key. There is no password 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"
}
}'$body = '{
"html": "<h1>Hello from PaperPony</h1><p>Rendered {{when}}.</p>",
"data": {
"when": "just now"
}
}'
Invoke-RestMethod -Uri "https://api.paperpony.dev/v1/pdf/render" -Method Post `
-ContentType "application/json" `
-Headers @{ Authorization = "Bearer $env:PAPERPONY_API_KEY" } `
-Body $bodyconst response = await fetch("https://api.paperpony.dev/v1/pdf/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAPERPONY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"html": "<h1>Hello from PaperPony</h1><p>Rendered {{when}}.</p>",
"data": {
"when": "just now"
}
}),
});
if (!response.ok) throw new Error(`PaperPony answered ${response.status}`);
const job = await response.json();
console.log(job.output_url);import os, 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"
}
},
timeout=30,
)
response.raise_for_status()
print(response.json()["output_url"])<?php
$body = <<<'JSON'
{
"html": "<h1>Hello from PaperPony</h1><p>Rendered {{when}}.</p>",
"data": {
"when": "just now"
}
}
JSON;
$curl = curl_init('https://api.paperpony.dev/v1/pdf/render');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PAPERPONY_API_KEY'),
'Content-Type: application/json',
],
]);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException("PaperPony answered {$status}");
}
$job = json_decode($response, true);
echo $job['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>"
}'$body = '{
"name": "Invoice",
"product": "pdf",
"source": "<h1>Invoice {{number}}</h1><p>Due {{formatDate due_on}}</p><p>{{formatCurrency total currency=currency}}</p>"
}'
Invoke-RestMethod -Uri "https://api.paperpony.dev/v1/templates" -Method Post `
-ContentType "application/json" `
-Headers @{ Authorization = "Bearer $env:PAPERPONY_API_KEY" } `
-Body $bodyconst response = await fetch("https://api.paperpony.dev/v1/templates", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAPERPONY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": "Invoice",
"product": "pdf",
"source": "<h1>Invoice {{number}}</h1><p>Due {{formatDate due_on}}</p><p>{{formatCurrency total currency=currency}}</p>"
}),
});
if (!response.ok) throw new Error(`PaperPony answered ${response.status}`);
const job = await response.json();
console.log(job.output_url);import os, requests
response = 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>"
},
timeout=30,
)
response.raise_for_status()
print(response.json()["output_url"])<?php
$body = <<<'JSON'
{
"name": "Invoice",
"product": "pdf",
"source": "<h1>Invoice {{number}}</h1><p>Due {{formatDate due_on}}</p><p>{{formatCurrency total currency=currency}}</p>"
}
JSON;
$curl = curl_init('https://api.paperpony.dev/v1/templates');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PAPERPONY_API_KEY'),
'Content-Type: application/json',
],
]);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException("PaperPony answered {$status}");
}
$job = json_decode($response, true);
echo $job['output_url'];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"
}
}'$body = '{
"template_id": "tpl_01JZ3M8Q0000000000000000AB",
"data": {
"number": "INV-0001",
"due_on": "2026-08-25",
"total": 4308,
"currency": "USD"
}
}'
Invoke-RestMethod -Uri "https://api.paperpony.dev/v1/pdf/render" -Method Post `
-ContentType "application/json" `
-Headers @{ Authorization = "Bearer $env:PAPERPONY_API_KEY" } `
-Body $bodyconst response = await fetch("https://api.paperpony.dev/v1/pdf/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAPERPONY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"template_id": "tpl_01JZ3M8Q0000000000000000AB",
"data": {
"number": "INV-0001",
"due_on": "2026-08-25",
"total": 4308,
"currency": "USD"
}
}),
});
if (!response.ok) throw new Error(`PaperPony answered ${response.status}`);
const job = await response.json();
console.log(job.output_url);import os, requests
response = requests.post(
"https://api.paperpony.dev/v1/pdf/render",
headers={
"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}",
},
json={
"template_id": "tpl_01JZ3M8Q0000000000000000AB",
"data": {
"number": "INV-0001",
"due_on": "2026-08-25",
"total": 4308,
"currency": "USD"
}
},
timeout=30,
)
response.raise_for_status()
print(response.json()["output_url"])<?php
$body = <<<'JSON'
{
"template_id": "tpl_01JZ3M8Q0000000000000000AB",
"data": {
"number": "INV-0001",
"due_on": "2026-08-25",
"total": 4308,
"currency": "USD"
}
}
JSON;
$curl = curl_init('https://api.paperpony.dev/v1/pdf/render');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('PAPERPONY_API_KEY'),
'Content-Type: application/json',
],
]);
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException("PaperPony answered {$status}");
}
$job = json_decode($response, true);
echo $job['output_url'];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.