PaperPony

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.

Put it in your environment
export PAPERPONY_API_KEY=pp_live_your_key_here

A 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 $body
const 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:

200 OK
{
  "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 $body
const 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 $body
const 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