How to Generate a PDF Certificate of Completion Automatically
Issue branded PDF certificates when a user completes a course, passes a test, or hits a milestone — automatically, at scale. No Canva, no manual exports, no certificate builder subscription.
Course platforms, bootcamps, compliance training tools, and onboarding flows all need to issue certificates. The typical workflow: design one in Canva, export as PDF, manually fill in the name and date, email it. Fine for 10 users. Unusable at 1,000.
Here's how to generate a branded certificate for every completion event — triggered by a webhook, a database event, or an API call — with zero manual work per certificate.
Certificate HTML template
function renderCertificateHtml({
recipientName,
courseName,
completionDate,
instructorName,
certificateId,
hoursCompleted,
}) {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;500;600&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1056px;
height: 816px;
background: #fff;
font-family: 'Inter', sans-serif;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* Decorative border */
.border-frame {
position: absolute;
inset: 24px;
border: 2px solid #c9a96e;
pointer-events: none;
}
.border-frame::before {
content: '';
position: absolute;
inset: 6px;
border: 1px solid #c9a96e;
opacity: 0.4;
}
/* Corner ornaments */
.corner {
position: absolute;
width: 32px;
height: 32px;
border-color: #c9a96e;
border-style: solid;
}
.corner-tl { top: 18px; left: 18px; border-width: 3px 0 0 3px; }
.corner-tr { top: 18px; right: 18px; border-width: 3px 3px 0 0; }
.corner-bl { bottom: 18px; left: 18px; border-width: 0 0 3px 3px; }
.corner-br { bottom: 18px; right: 18px; border-width: 0 3px 3px 0; }
.content { text-align: center; padding: 48px 80px; position: relative; z-index: 1; }
.org-name { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 4px; color: #c9a96e; margin-bottom: 16px; }
.cert-title { font-family: 'Playfair Display', serif; font-size: 42px; color: #1a1a1a; line-height: 1.2; margin-bottom: 20px; }
.presents { font-size: 14px; color: #888; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 12px; }
.recipient { font-family: 'Playfair Display', serif; font-size: 52px; color: #1a1a1a; margin-bottom: 20px; font-style: italic; }
.completion-text { font-size: 15px; color: #555; line-height: 1.6; max-width: 600px; margin: 0 auto 24px; }
.course-name { font-size: 22px; font-weight: 700; color: #1a1a1a; margin-bottom: 8px; }
.meta { display: flex; justify-content: center; gap: 40px; margin: 24px 0 32px; }
.meta-item { text-align: center; }
.meta-value { font-size: 15px; font-weight: 600; color: #1a1a1a; }
.meta-label { font-size: 11px; color: #999; text-transform: uppercase; letter-spacing: 1px; margin-top: 2px; }
.divider { width: 80px; height: 2px; background: #c9a96e; margin: 20px auto; }
.signatures { display: flex; justify-content: center; gap: 80px; margin-top: 28px; }
.sig { text-align: center; }
.sig-line { width: 160px; border-bottom: 1px solid #999; margin-bottom: 6px; height: 32px; }
.sig-name { font-size: 13px; font-weight: 600; color: #1a1a1a; }
.sig-title { font-size: 11px; color: #999; margin-top: 2px; }
.cert-id { position: absolute; bottom: 36px; right: 56px; font-size: 10px; color: #ccc; letter-spacing: 0.5px; }
</style>
</head>
<body>
<div class="border-frame"></div>
<div class="corner corner-tl"></div>
<div class="corner corner-tr"></div>
<div class="corner corner-bl"></div>
<div class="corner corner-br"></div>
<div class="content">
<div class="org-name">YourAcademy</div>
<div class="cert-title">Certificate of Completion</div>
<div class="divider"></div>
<div class="presents">This certifies that</div>
<div class="recipient">${recipientName}</div>
<div class="completion-text">has successfully completed all requirements for</div>
<div class="course-name">${courseName}</div>
<div class="meta">
<div class="meta-item">
<div class="meta-value">${completionDate}</div>
<div class="meta-label">Date of completion</div>
</div>
${hoursCompleted ? `
<div class="meta-item">
<div class="meta-value">${hoursCompleted} hours</div>
<div class="meta-label">Course duration</div>
</div>` : ""}
<div class="meta-item">
<div class="meta-value">${certificateId}</div>
<div class="meta-label">Certificate ID</div>
</div>
</div>
<div class="divider"></div>
<div class="signatures">
<div class="sig">
<div class="sig-line"></div>
<div class="sig-name">${instructorName}</div>
<div class="sig-title">Course Instructor</div>
</div>
<div class="sig">
<div class="sig-line"></div>
<div class="sig-name">Academy Director</div>
<div class="sig-title">YourAcademy</div>
</div>
</div>
</div>
<div class="cert-id">Certificate ID: ${certificateId}</div>
</body>
</html>`;
}
Generate and email on course completion
async function issueCertificate(user, course) {
const certificateId = `CERT-${Date.now().toString(36).toUpperCase()}`;
const html = renderCertificateHtml({
recipientName: user.name,
courseName: course.name,
completionDate: new Date().toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
}),
instructorName: course.instructorName,
certificateId,
hoursCompleted: course.totalHours,
});
const res = await fetch("https://pagebolt.dev/api/v1/pdf", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ html }),
});
const pdf = Buffer.from(await res.arrayBuffer());
// Store in S3
const cdnUrl = await uploadToCDN(pdf, `certificates/${certificateId}.pdf`);
// Save to DB
await db.certificates.create({
data: { userId: user.id, courseId: course.id, certificateId, pdfUrl: cdnUrl },
});
// Email to user
await sendCertificateEmail({ user, course, pdf, certificateId });
return { certificateId, pdfUrl: cdnUrl };
}
Webhook handler (Teachable, Thinkific, custom LMS)
app.post("/webhooks/course-completed", async (req, res) => {
res.json({ ok: true }); // acknowledge immediately
const { userId, courseId } = req.body;
const [user, course] = await Promise.all([
db.users.findById(userId),
db.courses.findById(courseId),
]);
const { certificateId, pdfUrl } = await issueCertificate(user, course);
console.log(`Certificate issued: ${certificateId} → ${user.email}`);
});
Batch issue for existing completions
async function backfillCertificates() {
const completions = await db.completions.findMany({
where: { certificateIssued: false },
include: { user: true, course: true },
});
console.log(`Issuing ${completions.length} certificates...`);
for (let i = 0; i < completions.length; i += 5) {
const batch = completions.slice(i, i + 5);
await Promise.allSettled(
batch.map(({ user, course }) => issueCertificate(user, course))
);
console.log(`${Math.min(i + 5, completions.length)}/${completions.length}`);
}
}
Verify certificate authenticity (public endpoint)
app.get("/certificates/:id/verify", async (req, res) => {
const cert = await db.certificates.findOne({
where: { certificateId: req.params.id },
include: { user: true, course: true },
});
if (!cert) return res.status(404).json({ valid: false });
res.json({
valid: true,
recipient: cert.user.name,
course: cert.course.name,
issuedAt: cert.createdAt,
certificateId: cert.certificateId,
});
});
Add a QR code to the certificate that links to this endpoint — scannable proof of authenticity.
Try it free
100 requests/month, no credit card required. OG images, screenshots, PDFs, and video — one API.
Get API Key — Free