Issuing 5,000 course certificates in one afternoon
A spreadsheet of names, one landscape template, batch requests of 100, and signed links per student. The full workflow including naming, verification codes and delivery.
Training platforms hit this once a cohort finishes: hundreds or thousands of certificates, each with a name, a course, a date and a unique code, due by Friday. It is the textbook batch job, and it is mostly a data problem rather than a rendering one.
Get the data right first
One row per certificate:
| Column | Example | Notes |
|---|---|---|
| recipient | Ada Lovelace | As it should appear — check capitalisation |
| course | Advanced Data Analysis with Python | Exact title |
| completed | 31 August 2026 | Formatted, not a raw timestamp |
| hours | 40 hours | If your accreditation requires it |
| cert_id | NA-7K2M-9QXA | Random, not sequential |
| [email protected] | For delivery |
The one that matters is cert_id. Sequential codes let anyone enumerate other students' certificates by incrementing a number — a real privacy problem when the certificate carries a full name and an employer's name. Generate them randomly:
import secrets
cert_id = "-".join(secrets.token_hex(2).upper() for _ in range(2)) # e.g. 7K2M-9QXA
The template
Landscape A4, the recipient's name as the largest element, then issuer, course, date, signatory and the code. The built-in certificate template is exactly that; or send your own HTML with your branding and seal.
Two typography notes learned the hard way: names vary enormously in length, so size the name field to fit "Bartholomew Featherstonehaugh" rather than "Ada Lovelace"; and if you print the code, use a monospace font so 0 and O are distinguishable when someone types it in.
Batching
Send batches of 100:
import requests
KEY = os.environ["PDFGENY_KEY"]
HEAD = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
def chunks(rows, n=100):
for i in range(0, len(rows), n):
yield rows[i:i + n]
batch_ids = []
for chunk in chunks(students):
docs = [{
"template": "certificate",
"data": {"recipient": s.name, "course": s.course, "date": s.completed,
"hours": s.hours, "cert_id": s.cert_id, "issuer": "Northwind Academy",
"signatory": "Dr. Priya Nair, Head of Faculty"},
"filename": f"{s.cert_id}.pdf",
} for s in chunk]
r = requests.post("https://pdfgeny.com/api/v1/batch", headers=HEAD,
json={"documents": docs}, timeout=30)
r.raise_for_status()
batch_ids.append(r.json()["batch_id"])
Fifty batches for five thousand certificates. Each returns immediately; rendering happens in the background at roughly 0.65 s per document across the worker pool.
Collecting the results
Poll each batch, or receive a webhook per document. Polling is simpler for a one-off run:
for bid in batch_ids:
while True:
s = requests.get(f"https://pdfgeny.com/api/v1/batch/{bid}", headers=HEAD).json()
if s["status"] == "done":
break
time.sleep(5)
for job in s["jobs"]:
if job["status"] == "done":
pdf = requests.get(job["document_url"]).content
save_to_storage(job["id"], pdf)
else:
failed.append((job["id"], job.get("error")))
Keep the failed list. In a run of five thousand, one or two will fail for silly reasons — a name with an unescaped character, an empty course field — and you want to re-run those rather than the whole cohort.
A verification page
Publish /verify/ showing the holder, the course and the completion date. The code on the PDF then becomes checkable proof, and employers stop emailing you to ask whether a certificate is real.
Keep it minimal: name, course, date, issued-by. Do not expose the email address or anything else about the student.
Delivery
Email each student a link rather than an attachment. Five thousand emails with a 200 KB attachment is a gigabyte of mail that spam filters will treat with suspicion; five thousand emails with a link is not.
send_email(student.email,
subject=f"Your certificate — {student.course}",
body=render_template("certificate_email.txt",
name=student.name, url=signed_url(student.cert_id)))
Store your own copy in object storage for re-issue. Students lose certificates; the ability to regenerate one without re-running the batch is worth the storage cost.
Timing a real run
For five thousand certificates on our fleet: fifty batch calls take about a minute to submit, the queue drains in roughly fifteen minutes at six concurrent renders, and collection plus emailing is bounded by your mail provider rather than by rendering. Plan an hour end to end, not an afternoon.
If you are running your own renderer instead, read how to generate 10,000 PDFs without running out of memory before the cohort finishes, not after.