The day your first cohort completes a course is the day certificates stop being a design job and become an engineering problem. One certificate is a Canva export. Ten thousand is a rendering pipeline with a database table, a queue and a verification page.
This post walks through the three ways teams actually build that pipeline, with working Python for each, then covers the two parts most certificate tutorials skip: batching at volume and verification. It is a condensed version of our full guide, How to generate signed digital certificates at scale, which also covers storage, retention and revocation.
One scope note up front. Most platform certificates do not need cryptographic signing in the PKI sense. The trust model that 95% of platforms ship is simpler: a unique ID printed on the certificate resolves to a verification page on the issuer's domain. An employer types the ID, the page confirms it. That is the model this post builds. If you need true PKI signing for regulated credentials, the stack is different (Adobe Sign, DocuSign, in-house HSM workflows) and this post is not it.
What every certificate needs
Whichever approach you pick, the output is the same:
| Component | Detail |
|---|---|
| Layout | Landscape A4, 2480x1754 at 200 DPI for print |
| Personal | Recipient name with full Unicode support |
| Course | Course title and completion date |
| Issuer | Issuer name plus a signature image |
| ID | Unique certificate ID (UUID or short slug) |
| Verify | A URL under the ID pointing to your /verify route |
The signature image communicates authority but provides zero tamper resistance. The certificate ID plus the verification page is the practical trust layer. Keep both in mind as you read the code.
The three approaches at a glance
| Approach | Setup | Render time | Maintenance |
|---|---|---|---|
| PDF library (ReportLab, PDFKit) | 1 day | 200 to 400 ms | Fonts, layout drift, library updates |
| HTML plus headless Chrome | 2 hours | 1 to 3 sec | Chromium, memory, queue workers |
| Template API | 5 minutes | 1 to 2 sec | None |
Approach 1: a PDF library
Python with ReportLab is the canonical first attempt. The output is a real PDF, text-searchable and around 30 KB per certificate, and you have absolute control.
import io
from uuid import uuid4
from reportlab.lib.pagesizes import landscape, A4
from reportlab.lib.colors import HexColor
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
def generate(recipient, course, completion_date,
issuer, signature_path, accent='#0F766E'):
width, height = landscape(A4)
cert_id = uuid4().hex[:12]
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=landscape(A4))
c.setFillColor(HexColor(accent))
c.rect(0, height - 80, width, 80, fill=1, stroke=0)
c.setFillColor(HexColor('#FFFFFF'))
c.setFont('Helvetica-Bold', 28)
c.drawCentredString(width / 2, height - 55, 'Certificate of Completion')
c.setFillColor(HexColor('#1F2937'))
c.setFont('Helvetica-Bold', 36)
c.drawCentredString(width / 2, height - 240, recipient)
c.setFont('Helvetica-Bold', 22)
c.drawCentredString(width / 2, height - 340, course)
c.setFont('Helvetica', 14)
c.drawCentredString(width / 2, height - 380, f'on {completion_date}')
sig = ImageReader(signature_path)
c.drawImage(sig, width / 2 - 80, 120, width=160, height=60,
preserveAspectRatio=True, mask='auto')
c.setFont('Helvetica', 9)
c.drawString(40, 30, f'Certificate ID: {cert_id}')
c.drawRightString(width - 40, 30,
f'Verify at northwindstudio.com/verify/{cert_id}')
c.save()
return buffer.getvalue()
That runs. But once you add Unicode font registration (default Helvetica only covers Latin Extended, so "Müller" and "Çelik" need a registered font), signature scaling and line wrapping for long course titles, you are at roughly 250 lines. Layout drift creeps in with every design change, because every element is a coordinate calculation. The output also looks like a 2008 PDF unless you put real design effort in.
Pick this when you need low-level PDF control, your design barely changes and someone on the team is comfortable living in PDF coordinate space.
Approach 2: an HTML template plus headless Chrome
If your team thinks in HTML and CSS rather than PDF coordinates, this is the natural next step. The certificate becomes the same Jinja, Blade or EJS template you would use for any other branded output, and you iterate on it in a normal browser before pointing Playwright at it.
from playwright.sync_api import sync_playwright
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader('templates'))
template = env.get_template('certificate.html')
def generate(recipient, course, completion_date, issuer,
signature_url, cert_id, accent='#0F766E'):
html = template.render(
recipient=recipient, course=course,
completion_date=completion_date, issuer=issuer,
signature_url=signature_url, cert_id=cert_id, accent=accent,
)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 2480, 'height': 1754})
page.set_content(html, wait_until='networkidle')
png = page.screenshot(full_page=False)
browser.close()
return png
The template stays under 60 lines of HTML and CSS. Real fonts via Google Fonts, Flexbox for positioning, a background colour for the accent band.
The honest cost is the same as any headless Chrome workflow: Chromium on every worker, 200 to 400 MB resident per instance, and cold starts of 3 to 5 seconds on serverless. Batching 8,000 certificates a month on a t3.small will OOM. Under 1,000 a month on infrastructure you already run, this is the right pick. Above that, the render infrastructure starts costing more attention than it saves.
Approach 3: a template API
The lowest-setup path is to send the data to a hosted template and get an image back. The certificate-of-completion template accepts the fields an LMS has to hand and returns a print-ready 2480x1754 PNG, or a PDF when you set format to pdf.
import os
import requests
def generate(recipient, course, completion_date, issuer,
signature_url, cert_id, accent='#0F766E'):
response = requests.post(
'https://app.html2img.com/api/v1/templates/certificate-of-completion',
headers={'X-API-Key': os.environ['HTML_TO_IMAGE_KEY']},
json={
'recipient_name': recipient,
'course_name': course,
'completion_date': completion_date,
'issuer_name': issuer,
'issuer_signature_url': signature_url,
'certificate_id': cert_id,
'accent_color': accent,
},
timeout=15,
)
response.raise_for_status()
return response.json()['url']
No Chromium, no fonts, no workers. The trade is a credit per render, so run the maths against your real volume: a platform issuing 200 certificates a week (10,400 a year) sits comfortably inside a $60 a month plan, and a $25 plan covers 3,000 renders a month.
Batching 10,000
Here is where the title's number earns its place. A cohort completing together looks like this:
from uuid import uuid4
from datetime import datetime
def issue_cohort(cohort_id, course_name, completion_date, signature_url):
students = db.query(Enrollment).filter_by(
cohort_id=cohort_id, completed=True
).all()
for student in students:
cert_id = uuid4().hex[:12]
url = generate(
recipient=student.full_name,
course=course_name,
completion_date=completion_date,
issuer='Northwind Studio',
signature_url=signature_url,
cert_id=cert_id,
)
db.session.add(Certificate(
student_id=student.id,
certificate_id=cert_id,
image_url=url,
issued_at=datetime.utcnow(),
))
db.session.commit()
That loop is fine for a hundred students. At 1 to 2 seconds per render, 10,000 certificates issued serially take three to five hours, and your worker hangs on every request. The fix is to stop waiting. Pass a webhook URL instead and let the renders come back to you:
response = requests.post(
'https://app.html2img.com/api/v1/templates/certificate-of-completion',
headers={'X-API-Key': os.environ['HTML_TO_IMAGE_KEY']},
json={
'recipient_name': recipient,
'course_name': course,
'completion_date': completion_date,
'issuer_name': issuer,
'issuer_signature_url': signature_url,
'certificate_id': cert_id,
'webhook_url': f'https://northwindstudio.com/webhooks/cert/{cert_id}',
},
)
The dispatch loop now takes minutes, and the webhook handler updates each Certificate row with the rendered URL as it lands. The same pattern applies to Approach 2 if you push renders onto a queue: fire the jobs, collect results asynchronously, never block issuance on rendering.
Two batching rules regardless of approach. Generate the certificate ID at dispatch time, not render time, so a retried render is idempotent and never mints a duplicate. And store the image URL in the database rather than the bytes, backing it up to your own S3 bucket if you have multi-year retention requirements.
The verification page
This is the part every certificate tutorial skips, and it is the part that makes the certificate worth anything. A printed ID without a resolver is just a number. The trust anchor is the page on your domain:
from flask import Flask, render_template, abort
app = Flask(__name__)
@app.route('/verify/<cert_id>')
def verify(cert_id):
cert = db.query(Certificate).filter_by(
certificate_id=cert_id
).first()
if not cert:
abort(404)
return render_template(
'verify.html',
recipient=cert.student.full_name,
course=cert.course_name,
issued_at=cert.issued_at,
image_url=cert.image_url,
)
The page shows the recipient, the course, the issue date and the certificate image itself. The certificate links here via the printed URL, the page confirms the certificate. The loop closes.
Three design notes from running this in production:
- Use UUIDs or 12-character hex for the ID, never auto-increment integers. Sequential IDs leak your issuance volume to anyone holding two certificates.
- Serve a plain 404 for revoked certificates, not a "this was revoked" page. An employer hitting a 404 will contact you directly, which is exactly what you want in the rare revocation case.
- Let the verify pages be indexed. They are your trust anchor and you want search engines to know they exist.
Picking one
Under 1,000 certificates a month on infrastructure you already maintain, headless Chrome is fine. If you need embedded PDF metadata and your design is frozen, ReportLab earns its 250 lines. In the 100 to 10,000 a month band, or if you would rather spend engineering time on the learning platform than on render infrastructure, the template API is the sensible default, and there is a free tier to test it on a small cohort first.
The full guide, including storage, retention, diacritics handling and adding a QR code to the verify flow, is over on HTML to Image.
How are you issuing certificates on your platform, and has the verification side ever come up with employers? Share your setup in the comments.
Top comments (0)