Certificate Generation at Scale: Use Cases and Implementation

A practical guide to generating certificates programmatically — from course completions to compliance documents — including templating, batching, and delivery patterns.

pdfcertificatesnode.jsautomationscaling

Certificate Generation at Scale: Use Cases and Implementation

Certificates look simple from the outside: a name, a date, a signature, maybe a QR code. But once you need to issue 10,000 of them after a conference, or generate them on-demand as users complete courses, the engineering problem gets interesting. You're dealing with templating, queuing, storage, delivery, verification, and often localization — all at the same time.

This post walks through the common use cases and shows how to build a certificate pipeline that holds up under real load.

Common use cases

A few patterns show up repeatedly:

  • Course and training completion — LMS platforms issuing certificates when a user finishes a module.
  • Event attendance — webinars, bootcamps, conferences generating per-attendee PDFs post-event.
  • Compliance and audit — ISO, HIPAA, or safety training records that need verifiable issuance dates.
  • Credentials and badges — professional certifications with QR codes linking to a verification endpoint.
  • White-labeled issuance — SaaS platforms where each tenant has their own branded template.

Each shares the same core workflow: take a template, inject data, render to PDF, store it, deliver it.

Template design

Design the template as HTML + CSS, not as code. This keeps content changes out of your deploy pipeline and lets designers iterate.

A minimal template looks like this:

html
<!DOCTYPE html> <html> <head> <style> @page { size: A4 landscape; margin: 0; } body { font-family: 'Playfair Display', serif; text-align: center; } .name { font-size: 48px; margin-top: 220px; } .course { font-size: 24px; color: #555; } .qr { position: absolute; bottom: 40px; right: 40px; } </style> </head> <body> <h1 class="name">{{ recipient.name }}</h1> <p class="course">has completed {{ course.title }}</p> <p>Issued on {{ issuedAt | date: "MMMM d, yyyy" }}</p> <img class="qr" src="{{ verificationQr }}" /> </body> </html>

Keep the design print-friendly: fixed page size, no viewport-dependent units, embedded fonts.

Single-document rendering

Here's the baseline — one certificate, synchronously, using Kamy:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "course-certificate", data: { recipient: { name: "Ada Lovelace" }, course: { title: "Advanced Analytical Engines" }, issuedAt: new Date().toISOString(), verificationQr: `https://verify.example.com/${certId}.png`, }, }); console.log(pdf.url); // signed URL to the generated PDF

That works fine for a handful of certificates. It falls apart at thousands.

Scaling to batches

When you need to issue thousands at once — say, after a conference — don't loop and await. You'll hit rate limits, timeouts, and memory pressure.

Instead, push jobs onto a queue and process them with controlled concurrency:

ts
import { Queue, Worker } from "bullmq"; import { kamy } from "@kamydev/sdk"; const queue = new Queue("certificates"); // Enqueue for (const attendee of attendees) { await queue.add("render", { attendeeId: attendee.id, name: attendee.name, email: attendee.email, }); } // Worker new Worker( "certificates", async (job) => { const pdf = await kamy.documents.render({ template: "event-certificate", data: { recipient: { name: job.data.name }, issuedAt: new Date().toISOString(), }, }); await db.certificates.insert({ attendeeId: job.data.attendeeId, url: pdf.url, renderedAt: new Date(), }); }, { concurrency: 20 } );

Concurrency of 10–50 is usually a good starting point. Measure your render time per document and your API's rate limits before tuning further.

Batch endpoints

If your PDF service supports batch rendering, use it. One HTTP call that renders 500 documents is dramatically cheaper than 500 calls:

ts
const batch = await kamy.documents.renderBatch({ template: "event-certificate", items: attendees.map((a) => ({ key: a.id, data: { recipient: { name: a.name } }, })), }); for (const result of batch.results) { await db.certificates.insert({ attendeeId: result.key, url: result.url, }); }

Batching also simplifies retries — you can re-submit only the failed items by key.

Storage and delivery

Treat the PDF URL as cache, not source of truth. Store the input data (recipient, course, issue date) in your database so you can re-render on demand. This matters when:

  • Templates change and old certificates need regeneration.
  • Signed URLs expire.
  • You migrate PDF providers.

For delivery, send an email with a link rather than attaching the file. Attachments bloat deliverability metrics and often get stripped:

ts
await mailer.send({ to: attendee.email, subject: "Your certificate is ready", html: `<p>Download your certificate <a href="${pdf.url}">here</a>.</p>`, });

Verification endpoints

For anything compliance-adjacent, embed a QR code that points to a public verification URL:

ts
app.get("/verify/:certId", async (req, res) => { const cert = await db.certificates.findById(req.params.certId); if (!cert)