Tutorials

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.

M Mikel Rougstone · 31 August 2026 · 3 min read
Issuing 5,000 course certificates in one afternoon

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:

ColumnExampleNotes
recipientAda LovelaceAs it should appear — check capitalisation
courseAdvanced Data Analysis with PythonExact title
completed31 August 2026Formatted, not a raw timestamp
hours40 hoursIf your accreditation requires it
cert_idNA-7K2M-9QXARandom, not sequential
email[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.

M

Mikel Rougstone

Founder, PDFGeny

I build and run PDFGeny — the API, the rendering fleet and the template catalog. Most of what I write here comes from something that broke in production first.