PaperPony

PDF generation API

Turn HTML into a PDF with one API call

Send HTML and the data to fill it, and get back a signed URL, the page count and what the render cost. Templates live server-side, so your application sends data rather than markup. Non-Latin text comes out right because the fonts are in the image, not because the document asked politely.

Invoices, receipts, certificates, reports and shipping labels all come out of that same call, and the recipes carry a complete template for each of them.

The free tier is 100 pages a month, and a key takes about a minute: an email address, a link, no card. The invoice generator needs neither.

curl https://api.paperpony.dev/v1/pdf/render \
  -H "Authorization: Bearer $PAPERPONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "html": "<h1>Invoice {{number}}</h1>",
  "data": {
    "number": "INV-0001"
  }
}'
$body = '{
  "html": "<h1>Invoice {{number}}</h1>",
  "data": {
    "number": "INV-0001"
  }
}'
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>Invoice {{number}}</h1>",
  "data": {
    "number": "INV-0001"
  }
}),
});

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>Invoice {{number}}</h1>",
  "data": {
    "number": "INV-0001"
  }
},
    timeout=30,
)
response.raise_for_status()
print(response.json()["output_url"])
<?php

$body = <<<'JSON'
{
  "html": "<h1>Invoice {{number}}</h1>",
  "data": {
    "number": "INV-0001"
  }
}
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'];

Answers with the finished job: output_url, page_count, credits_charged. Full reference.

Measured on the machine that serves this API: a thousand consecutive renders of a twelve-line invoice carrying Cyrillic and Arabic, 590 ms median and 608 ms at the 95th percentile, no failures, and resident memory moving from 192 MB to 198 MB across the whole run.

One call, a finished file

Post HTML, or the id of a template you stored earlier along with the data to fill it. The response carries the signed URL, the page count and the credits charged. A render that finishes inside twenty seconds answers on that same request; a slower one answers 202 with a job id to poll. Failed renders and timeouts charge nothing.

Fonts that cover your customers

Chromium falls back to DejaVu Sans when fontconfig finds nothing better, and DejaVu has no Arabic glyphs, so Arabic comes out as empty boxes anywhere the image is thin. The render container installs the Noto core, CJK and mono families, and a script we run against the built image asserts that six scripts each resolve to their own family instead of the fallback.

Every document is treated as an attack

The HTML your callers send opens in a real browser, which is a general-purpose proxy into our network if nothing stops it. Outbound requests are resolved and validated at connect time rather than beforehand, so a hostname that answers first with a public address and then with 127.0.0.1 is refused outright. Fifteen attacks run in CI and each one is expected to fail.

The invoice generator is free, unconditionally

No account, no email, no watermark, no download counter. The page posts to a Cloudflare Worker that holds the key, and the Worker calls the same endpoint a paying customer calls. The file you download is the file the API produces, not a preview of it. Nothing about the API appears on screen until you have your PDF.

Open the invoice generator