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:
tsimport { 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:
tsimport { 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:
tsconst 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:
tsawait 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:
tsapp.get("/verify/:certId", async (req, res) => { const cert = await db.certificates.findById(req.params.certId); if (!cert)