One request per person
A course platform, a training team and an event organiser all face the same small chore. Someone finishes, and they deserve a certificate with their own name on it. Doing that by hand in a design tool works for ten people. It stops working at a hundred, and it is easy to misspell a name along the way.
With an API, the design is fixed and the details are data. Each request carries one person’s name, course, date and certificate number. Each response is one finished PDF, ready to email or offer as a download. Nobody retypes a name, and every certificate looks the same.
Certificates are one use of our general PDF generation API. The same endpoint makes invoices and reports, so if you already use it for something else, a certificate is just another template ID.
The certificate starter
The dashboard includes a certificate of completion you can use as it is or restyle. It is A4 landscape with no page margin, a navy border, gold corner marks and a round seal. Its sample data describes one learner who finished a data visualisation course:
{
"certificate_id": "HA-2026-04817",
"completed_on": "2026-09-18",
"issuer": { "name": "Harbourline Academy", "short": "Harbourline", "verify_url": "harbourline.example/verify" },
"recipient": { "name": "Amara Okafor" },
"course": { "title": "Advanced Data Visualisation", "hours": 24, "format": "Instructor-led, online", "grade": "Awarded with distinction" },
"signatory": { "name": "Elena Varga", "title": "Director of Studies" }
}| Field | On the certificate |
|---|---|
recipient.name | The large italic name in the middle |
course.title | The course line under the name |
course.hours, course.format, course.grade | One small line of details under the course |
completed_on | Printed as a long date on the left, and its year on the seal |
signatory.name, signatory.title | The signature line on the right |
issuer.name, issuer.short | The heading above the title, and the text on the seal |
certificate_id, issuer.verify_url | The small line at the bottom, for checking it later |
To make a certificate for someone else, send their details with your template ID. Any field you leave out simply prints nothing, so send all of them:
curl https://api.castpdf.com/v1/pdf \
-H "Authorization: Bearer $CASTPDF_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cert-HA-2026-05102" \
-d '{
"template_id": "'"$CERTIFICATE_TEMPLATE_ID"'",
"data": {
"certificate_id": "HA-2026-05102",
"completed_on": "2026-10-02",
"issuer": {"name": "Harbourline Academy", "short": "Harbourline", "verify_url": "harbourline.example/verify"},
"recipient": {"name": "Tomás Lindqvist"},
"course": {"title": "Practical SQL for Analysts", "hours": 16, "format": "Self-paced, online", "grade": "Completed"},
"signatory": {"name": "Elena Varga", "title": "Director of Studies"}
},
"filename": "certificate-HA-2026-05102"
}' \
--output certificate-HA-2026-05102.pdfThe starter sets its page in the template settings, not in its CSS, and its layout stretches to fill whatever page it gets. That is why the printing options further down work without editing the design. More styles are on the certificate template page.
Names, dates, signatures, logos and unique IDs
Names
Names are the one thing people check first. Send each name exactly as the person wrote it, accents and all. Output is HTML-escaped automatically, so a name with an ampersand or an angle bracket prints as text and cannot break the layout.
Very long names are the usual surprise. Type the longest name on your list into the preview before you go live. If it wraps badly, give long names a smaller size with a short Liquid check:
{% assign name_length = recipient.name | size %}
<p class="recipient{% if name_length > 26 %} recipient-long{% endif %}">{{ recipient.name }}</p>Then add a rule such as .recipient-long { font-size: 30pt; } to the CSS. Noto fonts for many alphabets are installed on the renderer, so names in Greek, Cyrillic, Arabic or Chinese have fonts available. Add the matching Noto family to your font stack if the design needs a particular look.
Dates
The starter prints completed_on with format_date: "long", "UTC", "en-GB", which reads as 18 September 2026. Change the locale to en-US for September 18, 2026, or to fr-FR for French. Send dates as plain YYYY-MM-DD strings and they never shift by a day.
Signatures and logos
Out of the box, the signer’s name is set in a script-like italic. For a real signature, scan it, save it as a PNG with a transparent background and add it to the template as an image. A logo works the same way.
- Host images at a public
httpsaddress, or embed them in the template asdata:URLs so nothing needs to load. - Addresses on
localhost, on private networks or behind a login cannot be fetched by the renderer. - A signature image that differs per signer can come from your data, as an image address in a field.
Unique IDs and checking
Each certificate should carry a number from your own system, such as HA-2026-05102, plus the address where someone can check it. CastPDF prints both. The checking page itself lives on your website: it looks up the number in your records and confirms the name and course.
There is no QR code helper in CastPDF today. If you want a scannable code, create the QR image in your own code with a library you trust. Then send it as a data: URL in your data and place it with an <img> tag.
Making hundreds at once
Here is the honest picture. There is no bulk button in the dashboard yet. Through the API you can send a whole class in one batch request: one item per person, or a CSV or XLSX file. You then download a ZIP when it is done, as the batch API describes. Or your code sends one request per person, one after another, which is the approach below.
In practice that is a short script. Export your list from your spreadsheet app or your course platform as a CSV file:
email,name,course,hours,grade,certificate_id,completed_on
[email protected],Amara Okafor,Practical SQL for Analysts,16,Completed,HA-2026-05101,2026-10-02
[email protected],Tomás Lindqvist,Practical SQL for Analysts,16,Completed with merit,HA-2026-05102,2026-10-02
[email protected],"Mei Chen, PhD",Practical SQL for Analysts,16,Completed,HA-2026-05103,2026-10-02Then loop over the rows. This Python script uses the built-in csv module, which copes with commas inside quoted names. It sends each learner’s certificate number as the Idempotency-Key and writes every download link to a second file:
import csv
import os
import time
import requests
API = "https://api.castpdf.com/v1/pdf"
API_KEY = os.environ["CASTPDF_API_KEY"]
TEMPLATE_ID = os.environ["CERTIFICATE_TEMPLATE_ID"]
def make_certificate(row):
key = f"cert-{row['certificate_id']}"
body = {
"template_id": TEMPLATE_ID,
"data": {
"certificate_id": row["certificate_id"],
"completed_on": row["completed_on"],
"issuer": {"name": "Harbourline Academy", "short": "Harbourline", "verify_url": "harbourline.example/verify"},
"recipient": {"name": row["name"]},
"course": {"title": row["course"], "hours": int(row["hours"]), "format": "Self-paced, online", "grade": row["grade"]},
"signatory": {"name": "Elena Varga", "title": "Director of Studies"},
},
"filename": key,
"response": "url",
}
headers = {"Authorization": f"Bearer {API_KEY}", "Idempotency-Key": key}
for attempt in range(5):
res = requests.post(API, headers=headers, json=body, timeout=60)
if res.status_code in (409, 429, 503):
time.sleep(int(res.headers.get("Retry-After", 2 ** attempt)))
continue
res.raise_for_status()
return res.json()["url"]
raise RuntimeError(f"gave up on {key}")
with open("learners.csv", newline="", encoding="utf-8") as src, open("links.csv", "w", newline="", encoding="utf-8") as dst:
writer = csv.writer(dst)
writer.writerow(["email", "name", "certificate_id", "url"])
for row in csv.DictReader(src):
url = make_certificate(row)
writer.writerow([row["email"], row["name"], row["certificate_id"], url])
print("done:", row["name"])The loop waits for each certificate before it asks for the next, so it never trips the limit on renders running at once. If the script stops halfway, run it again within 24 hours. Rows that already succeeded return their first PDF through the Idempotency-Key and are not counted twice. The Python developer page adds logging and error handling, and the Node.js page shows the same ideas in JavaScript.
Your plan sets the pace. These limits apply per workspace:
| Plan | Requests a minute | PDFs rendering at once | PDFs a month |
|---|---|---|---|
| Free | 30 | 1 | 100 |
| Starter | 60 | 2 | 2,500 |
| Growth | 120 | 3 | 10,000 |
| Pro | 300 | 4 | 50,000 |
| Scale | 600 | 4 | 200,000 |
On Free, the rate limit alone lets a class of 100 through in about 4 minutes. On Growth, a cohort of 500 needs about 5 minutes. A one-at-a-time loop also waits for each render, so a real run can take longer. Test keys are limited to 20 a minute, so run your full list with a live key once the design is final. A request over the limit gets a 429 with a Retry-After header, and the script above simply waits.
Certificates the moment someone finishes
A batch script suits a cohort that ends on one day. Self-paced courses are different: learners finish at all hours, and they want the certificate while the feeling is fresh. For that, make the certificate in the same place your app records the completion.
Your course or event software usually has a moment you can hook into, such as a lesson marked complete, a final quiz passed or a badge scanned at the door. Your server reacts to that moment, creates the certificate number, calls CastPDF and saves the result next to the learner’s record.
export async function issueCertificate(learner, course, certificateId) {
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `cert-${certificateId}`,
},
body: JSON.stringify({
template_id: process.env.CERTIFICATE_TEMPLATE_ID,
data: {
certificate_id: certificateId,
completed_on: new Date().toISOString().slice(0, 10),
issuer: { name: 'Harbourline Academy', short: 'Harbourline', verify_url: 'harbourline.example/verify' },
recipient: { name: learner.fullName },
course: { title: course.title, hours: course.hours, format: course.format, grade: learner.grade ?? 'Completed' },
signatory: { name: 'Elena Varga', title: 'Director of Studies' },
},
filename: `certificate-${certificateId}`,
}),
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
return Buffer.from(await res.arrayBuffer());
}Save the certificate number before you call the API. If the call fails, a background retry can use the same number and the same key, and the learner still ends up with exactly one certificate. Ready-made apps for course and event platforms are not available today, so this call always comes from your own server. The generate a PDF from JSON guide explains the template route in more depth.
Getting certificates to people
CastPDF makes the PDF. Sending it is up to your own email service or your platform, which already knows each learner’s address. There are two common ways to do it.
- Send the link. With
"response": "url", each certificate comes back as a signed link that works without an API key. Put it in your congratulations email. - Attach the file. Without
response, the answer is the PDF itself. Attach it to the email, or save it to your own storage first.
Signed links expire when your plan’s storage time ends, from 1 day on Free to 90 days on Scale. A certificate is something people keep for years. Copy each PDF into your own storage and link to it from the learner’s account page, so the download keeps working.
Only need a handful? A non-technical colleague can do it in the dashboard: open the template, type the person’s details into the Simple mode form and press Generate PDF.
Printing: A4 landscape or US Letter
Most certificates are printed in landscape, on A4 in most of the world and on US Letter in the United States and Canada. The two sizes are close but not the same, so pick the one your recipients use.
| Paper | Size in landscape | Request fields |
|---|---|---|
| A4 | 297 × 210 mm | "format": "A4", "orientation": "landscape" |
| US Letter | 11 × 8.5 in (279 × 216 mm) | "format": "Letter", "orientation": "landscape" |
| A5, for small awards | 210 × 148 mm | "format": "A5", "orientation": "landscape" |
The starter defaults to A4 landscape with no margin. Add "format": "Letter" to a request and the same design fills a Letter page instead. You can also change the default once in the template settings.
Home and office printers cannot print right to the edge of the paper. The starter already keeps its border away from the edge, but if people print at home, a small margin such as "margins": "6mm" adds a safe white frame. For a print shop, ask whether they want bleed before you send files.
What certificates cost
Each certificate is one PDF. The Free plan includes 100 live PDFs a month, which covers a small course, and its live PDFs carry a small "Made with CastPDF" line at the bottom. Starter is $19 a month for 2,500 PDFs with no footer, and Growth is $49 for 10,000.
Graduation season is uneven, and paid plans allow for it. Unused PDFs roll over for a month, and a busy month can go past the allowance up to a spending cap you choose. We email you at 80% and 100% of your PDFs. Every price is on the pricing page.