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

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.

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" }
  }'
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:

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>"
  }'
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