Recipe
Certificate template
A landscape certificate template for the PaperPony PDF API: centred serif typography, a decorative border and a verification code.
Worth knowing
- Landscape comes from options.orientation, not from the template. Rotating with CSS produces a page that is the right shape and the wrong size.
- The border is drawn with CSS rather than an image, so the certificate has no subresources and renders in one pass.
- A verification code that resolves to a page you control is what makes a certificate checkable. Print it. A certificate nobody can verify is a picture.
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; }
/* html too: a percentage height on body resolves against html, and with
html unsized both fall back to auto and the frame stops short. */
html { height: 100%; }
body {
font-family: 'Noto Serif', serif;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.frame {
width: 100%;
height: 100%;
padding: 14mm;
border: 2.5px solid {{accent}};
outline: 1px solid {{accent}};
outline-offset: 4mm;
display: flex;
flex-direction: column;
justify-content: center;
}
.kicker {
font-family: 'Noto Sans', sans-serif;
font-size: 9pt;
letter-spacing: 0.32em;
text-transform: uppercase;
color: {{accent}};
}
h1 { margin: 6mm 0 0; font-size: 30pt; font-weight: 400; letter-spacing: 0.06em; }
.recipient { margin-top: 10mm; font-size: 24pt; font-weight: 700; }
.reason { margin: 6mm auto 0; max-width: 150mm; font-size: 12pt; color: #374151; }
.signatures { display: flex; justify-content: center; gap: 30mm; margin-top: 16mm; }
.signature { min-width: 55mm; }
.signature .line { border-top: 1px solid #111827; padding-top: 2mm; }
.signature .role {
font-family: 'Noto Sans', sans-serif;
font-size: 8pt;
color: #6b7280;
}
.verify {
font-family: 'Noto Sans Mono', monospace;
margin-top: 12mm;
font-size: 7.5pt;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="frame">
<div class="kicker">{{kicker}}</div>
<h1>{{title}}</h1>
<div class="recipient">{{recipient}}</div>
<p class="reason">{{reason}}</p>
<div class="signatures">
{{#each signatories}}
<div class="signature">
<div class="line">{{this.name}}</div>
<div class="role">{{this.role}}</div>
</div>
{{/each}}
</div>
<div class="verify">
Issued {{formatDate issued_on dateStyle="long"}} · Verify at {{verify_url}} · {{code}}
</div>
</div>
</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": "Certificate of completion",
"title": "Advanced Typesetting",
"recipient": "Amelia Okonkwo",
"reason": "has completed the forty-hour Advanced Typesetting programme, covering pagination, hyphenation and the preparation of print-ready artwork.",
"accent": "#7c5c1e",
"issued_on": "2026-07-26",
"signatories": [
{
"name": "Dr Helen Marsh",
"role": "Programme director"
},
{
"name": "Tomas Klein",
"role": "Head of studies"
}
],
"verify_url": "example.edu/verify",
"code": "CT-2026-3F91-AA7C"
},
"options": {
"format": "A4",
"orientation": "landscape",
"margin": {
"top": "10mm",
"right": "10mm",
"bottom": "10mm",
"left": "10mm"
}
}
}'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": "Certificate of completion",
"title": "Advanced Typesetting",
"recipient": "Amelia Okonkwo",
"reason": "has completed the forty-hour Advanced Typesetting programme, covering pagination, hyphenation and the preparation of print-ready artwork.",
"accent": "#7c5c1e",
"issued_on": "2026-07-26",
"signatories": [
{
"name": "Dr Helen Marsh",
"role": "Programme director"
},
{
"name": "Tomas Klein",
"role": "Head of studies"
}
],
"verify_url": "example.edu/verify",
"code": "CT-2026-3F91-AA7C"
},
"options": {
"format": "A4",
"orientation": "landscape",
"margin": {
"top": "10mm",
"right": "10mm",
"bottom": "10mm",
"left": "10mm"
}
}
});
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": "Certificate of completion",
"title": "Advanced Typesetting",
"recipient": "Amelia Okonkwo",
"reason": "has completed the forty-hour Advanced Typesetting programme, covering pagination, hyphenation and the preparation of print-ready artwork.",
"accent": "#7c5c1e",
"issued_on": "2026-07-26",
"signatories": [
{
"name": "Dr Helen Marsh",
"role": "Programme director"
},
{
"name": "Tomas Klein",
"role": "Head of studies"
}
],
"verify_url": "example.edu/verify",
"code": "CT-2026-3F91-AA7C"
},
"options": {
"format": "A4",
"orientation": "landscape",
"margin": {
"top": "10mm",
"right": "10mm",
"bottom": "10mm",
"left": "10mm"
}
}
},
)
response.raise_for_status()
print(response.json()["output_url"])