PaperPony

Recipe

Report template

A multi-page report template for the PaperPony PDF API with a cover page, running footer, page numbers and long tables that paginate correctly.

Worth knowing

  • break-before: page on each section is what gives a report its structure. It is one line and it is the difference between a document and a printout.
  • The cover is a section like any other; it just happens to be first and to set its own height.
  • Long tables are the thing reports get wrong. Repeat the head, avoid splitting rows, and keep each heading with the paragraph after it.

The template

Store it once with POST /v1/templates. It is compiled when you store it, so a mistake surfaces now rather than on the first render.

report.hbs
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    *, *::before, *::after { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: 'Noto Sans', sans-serif;
      font-size: 10pt;
      line-height: 1.5;
      color: #111827;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
    table { border-collapse: collapse; width: 100%; }
    thead { display: table-header-group; }
    tr { break-inside: avoid; }
    .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
    h1 { font-size: 26pt; letter-spacing: -0.01em; margin: 0; }
    h2 { font-size: 15pt; margin: 0 0 3mm; break-after: avoid; }
    h3 { font-size: 11pt; margin: 6mm 0 2mm; break-after: avoid; }
    p { margin: 0 0 3mm; }
    .cover {
      height: 235mm;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      border-bottom: 3px solid {{accent}};
    }
    .cover .meta { color: #6b7280; }
    .kicker {
      font-size: 8.5pt;
      letter-spacing: 0.18em;
      text-transform: uppercase;
      color: {{accent}};
    }
    section.chapter { break-before: page; padding-top: 4mm; }
    table.data { margin-top: 4mm; font-size: 9pt; }
    table.data th {
      padding: 2.5mm 3mm;
      border-bottom: 1.5px solid {{accent}};
      text-align: left;
      font-size: 8pt;
      letter-spacing: 0.08em;
      text-transform: uppercase;
    }
    table.data td { padding: 2.5mm 3mm; border-bottom: 1px solid #eef0f3; }
    .callout {
      break-inside: avoid;
      margin: 5mm 0;
      border-left: 3px solid {{accent}};
      padding: 3mm 4mm;
      background: #f9fafb;
    }
  </style>
</head>
<body>
  <section class="cover">
    <div>
      <div class="kicker">{{kicker}}</div>
      <h1 style="margin-top:6mm">{{title}}</h1>
      <p class="meta" style="margin-top:4mm">{{subtitle}}</p>
    </div>
    <div class="meta">
      <div>{{organisation}}</div>
      <div>{{formatDate published_on dateStyle="long"}}</div>
    </div>
  </section>

  {{#each sections}}
    <section class="chapter">
      <h2>{{this.heading}}</h2>
      {{#each this.paragraphs}}
        <p>{{this}}</p>
      {{/each}}

      {{#if this.callout}}
        <div class="callout">{{this.callout}}</div>
      {{/if}}

      {{#if this.table}}
        <h3>{{this.table.caption}}</h3>
        <table class="data">
          <thead>
            <tr>{{#each this.table.columns}}<th>{{this}}</th>{{/each}}</tr>
          </thead>
          <tbody>
            {{#each this.table.rows}}
              <tr>{{#each this}}<td>{{this}}</td>{{/each}}</tr>
            {{/each}}
          </tbody>
        </table>
      {{/if}}
    </section>
  {{/each}}
</body>
</html>

Rendering it

Substitute the template id you got back, and send this payload.

curl https://api.paperpony.dev/v1/pdf/render \
  -H "Authorization: Bearer $PAPERPONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "template_id": "tpl_YOUR_TEMPLATE_ID",
  "data": {
    "kicker": "Quarterly review",
    "title": "Platform reliability, Q2 2026",
    "subtitle": "Incidents, latency and what we changed",
    "organisation": "Contoso Engineering",
    "published_on": "2026-07-01",
    "accent": "#1d4ed8",
    "sections": [
      {
        "heading": "Summary",
        "paragraphs": [
          "Availability finished the quarter at 99.97%, against a target of 99.9%. Two incidents accounted for almost all of the downtime, and both had the same root cause.",
          "Median render latency fell by 18% after the browser pool was made to keep contexts warm across jobs."
        ],
        "callout": "The single largest win was not a code change. It was raising the pool size from two to four, which cost 400 MB and removed a cold start from a third of all renders."
      },
      {
        "heading": "Incidents",
        "paragraphs": [
          "Both incidents began with a queue backlog rather than a failure, which is why neither triggered an alert until customers noticed."
        ],
        "table": {
          "caption": "Incidents by duration",
          "columns": [
            "Date",
            "Duration",
            "Cause",
            "Fixed by"
          ],
          "rows": [
            [
              "12 May",
              "41 min",
              "Queue backlog",
              "Concurrency raised"
            ],
            [
              "3 June",
              "17 min",
              "Certificate expiry",
              "Renewal automated"
            ]
          ]
        }
      }
    ]
  },
  "options": {
    "format": "A4",
    "margin": {
      "top": "20mm",
      "right": "18mm",
      "bottom": "20mm",
      "left": "18mm"
    },
    "footer_html": "<div style=\"width:100%;padding:0 18mm;font-family:Helvetica,sans-serif;font-size:7.5pt;color:#9ca3af;display:flex;justify-content:space-between\"><span class=\"title\"></span><span>Page <span class=\"pageNumber\"></span> / <span class=\"totalPages\"></span></span></div>"
  }
}'
import { PaperPony } from 'paperpony';

const pony = new PaperPony({ apiKey: process.env.PAPERPONY_API_KEY });

const job = await pony.pdf.render({
  "template_id": "tpl_YOUR_TEMPLATE_ID",
  "data": {
    "kicker": "Quarterly review",
    "title": "Platform reliability, Q2 2026",
    "subtitle": "Incidents, latency and what we changed",
    "organisation": "Contoso Engineering",
    "published_on": "2026-07-01",
    "accent": "#1d4ed8",
    "sections": [
      {
        "heading": "Summary",
        "paragraphs": [
          "Availability finished the quarter at 99.97%, against a target of 99.9%. Two incidents accounted for almost all of the downtime, and both had the same root cause.",
          "Median render latency fell by 18% after the browser pool was made to keep contexts warm across jobs."
        ],
        "callout": "The single largest win was not a code change. It was raising the pool size from two to four, which cost 400 MB and removed a cold start from a third of all renders."
      },
      {
        "heading": "Incidents",
        "paragraphs": [
          "Both incidents began with a queue backlog rather than a failure, which is why neither triggered an alert until customers noticed."
        ],
        "table": {
          "caption": "Incidents by duration",
          "columns": [
            "Date",
            "Duration",
            "Cause",
            "Fixed by"
          ],
          "rows": [
            [
              "12 May",
              "41 min",
              "Queue backlog",
              "Concurrency raised"
            ],
            [
              "3 June",
              "17 min",
              "Certificate expiry",
              "Renewal automated"
            ]
          ]
        }
      }
    ]
  },
  "options": {
    "format": "A4",
    "margin": {
      "top": "20mm",
      "right": "18mm",
      "bottom": "20mm",
      "left": "18mm"
    },
    "footer_html": "<div style=\"width:100%;padding:0 18mm;font-family:Helvetica,sans-serif;font-size:7.5pt;color:#9ca3af;display:flex;justify-content:space-between\"><span class=\"title\"></span><span>Page <span class=\"pageNumber\"></span> / <span class=\"totalPages\"></span></span></div>"
  }
});

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={
    "template_id": "tpl_YOUR_TEMPLATE_ID",
    "data": {
        "kicker": "Quarterly review",
        "title": "Platform reliability, Q2 2026",
        "subtitle": "Incidents, latency and what we changed",
        "organisation": "Contoso Engineering",
        "published_on": "2026-07-01",
        "accent": "#1d4ed8",
        "sections": [
            {
                "heading": "Summary",
                "paragraphs": [
                    "Availability finished the quarter at 99.97%, against a target of 99.9%. Two incidents accounted for almost all of the downtime, and both had the same root cause.",
                    "Median render latency fell by 18% after the browser pool was made to keep contexts warm across jobs."
                ],
                "callout": "The single largest win was not a code change. It was raising the pool size from two to four, which cost 400 MB and removed a cold start from a third of all renders."
            },
            {
                "heading": "Incidents",
                "paragraphs": [
                    "Both incidents began with a queue backlog rather than a failure, which is why neither triggered an alert until customers noticed."
                ],
                "table": {
                    "caption": "Incidents by duration",
                    "columns": [
                        "Date",
                        "Duration",
                        "Cause",
                        "Fixed by"
                    ],
                    "rows": [
                        [
                            "12 May",
                            "41 min",
                            "Queue backlog",
                            "Concurrency raised"
                        ],
                        [
                            "3 June",
                            "17 min",
                            "Certificate expiry",
                            "Renewal automated"
                        ]
                    ]
                }
            }
        ]
    },
    "options": {
        "format": "A4",
        "margin": {
            "top": "20mm",
            "right": "18mm",
            "bottom": "20mm",
            "left": "18mm"
        },
        "footer_html": "<div style=\"width:100%;padding:0 18mm;font-family:Helvetica,sans-serif;font-size:7.5pt;color:#9ca3af;display:flex;justify-content:space-between\"><span class=\"title\"></span><span>Page <span class=\"pageNumber\"></span> / <span class=\"totalPages\"></span></span></div>"
    }
},
)
response.raise_for_status()
print(response.json()["output_url"])