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.
<!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"])