DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to generate a PDF certificate of completion automatically

How to Generate a PDF Certificate of Completion Automatically

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>`;
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
});
Enter fullscreen mode Exit fullscreen mode

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. → Get started in 2 minutes

Top comments (0)