PDFPipe

Use case

Education PDF API

Online course platforms, universities, and professional training providers need certificates the moment a learner finishes a course, end-of-term transcripts, and enrollment letters on demand. Each document is generated from an HTML template, stored, and returned as a stable link the student can download or share on LinkedIn.

Course completion certificate (TypeScript)

An HTML template with a decorative double border, the platform logo via img src, the learner name in a large display font, the course name, completion date, and a QR code from a QR API that links to your verification endpoint. Rendered with Mustache, posted with store: true, and the returned URL is saved to the enrollments table.

TypeScript
// handlers/generate-certificate.ts
import Mustache from "mustache";

const CERTIFICATE_HTML = `<!DOCTYPE html>
<html>
<head>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;500&display=swap');
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: 'Inter', sans-serif; color: #1a1a1a; background: #fff;
         width: 1122px; height: 793px; display: flex; align-items: center;
         justify-content: center; }
  .page { width: 100%; height: 100%; padding: 60px 80px;
          border: 14px solid #d23a1d; position: relative; }
  .inner-border { position: absolute; inset: 22px;
                  border: 2px solid #d23a1d; pointer-events: none; }
  .logo { display: flex; align-items: center; gap: 14px; margin-bottom: 36px; }
  .logo img { height: 44px; }
  .logo-name { font-family: 'Playfair Display', serif; font-size: 20px;
               font-weight: 700; color: #1a1a1a; }
  .label { font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
           color: #888; margin-bottom: 12px; }
  .cert-title { font-family: 'Playfair Display', serif; font-size: 42px;
                font-weight: 700; color: #d23a1d; margin-bottom: 20px; }
  .awarded-to { font-size: 13px; color: #666; margin-bottom: 8px; }
  .learner-name { font-family: 'Playfair Display', serif; font-size: 52px;
                  font-weight: 400; color: #1a1a1a; line-height: 1.1;
                  margin-bottom: 20px; border-bottom: 2px solid #e5e5e5;
                  padding-bottom: 20px; }
  .for-completing { font-size: 13px; color: #666; margin-bottom: 8px; }
  .course-name { font-size: 22px; font-weight: 600; color: #1a1a1a;
                 margin-bottom: 28px; }
  .meta { display: flex; gap: 60px; align-items: flex-start; margin-top: 4px; }
  .meta-col .meta-label { font-size: 11px; letter-spacing: 0.1em;
                           text-transform: uppercase; color: #999; margin-bottom: 4px; }
  .meta-col .meta-value { font-size: 14px; font-weight: 500; color: #1a1a1a; }
  .qr { margin-left: auto; }
  .qr img { width: 80px; height: 80px; }
  .qr .qr-label { font-size: 9px; text-align: center; color: #bbb;
                   margin-top: 4px; letter-spacing: 0.06em; }
  @page { margin: 0; size: A4 landscape; }
</style>
</head>
<body>
  <div class="page">
    <div class="inner-border"></div>
    <div class="logo">
      <img src="{{platformLogoUrl}}" alt="{{platformName}}" />
      <span class="logo-name">{{platformName}}</span>
    </div>

    <div class="label">Certificate of Completion</div>
    <div class="cert-title">This certifies that</div>

    <div class="awarded-to">the following learner has successfully completed</div>
    <div class="learner-name">{{learnerName}}</div>

    <div class="for-completing">the course</div>
    <div class="course-name">{{courseName}}</div>

    <div class="meta">
      <div class="meta-col">
        <div class="meta-label">Date Completed</div>
        <div class="meta-value">{{completionDate}}</div>
      </div>
      <div class="meta-col">
        <div class="meta-label">Certificate ID</div>
        <div class="meta-value">{{certificateId}}</div>
      </div>
      <div class="meta-col">
        <div class="meta-label">Instructor</div>
        <div class="meta-value">{{instructorName}}</div>
      </div>
      <div class="qr">
        <img src="https://api.qrserver.com/v1/create-qr-code/?size=80x80&data={{verifyUrl}}" alt="Verify" />
        <div class="qr-label">VERIFY</div>
      </div>
    </div>
  </div>
</body>
</html>`;

type CertificateData = {
  enrollmentId: string;
  learnerName: string;
  courseName: string;
  completionDate: string;
  instructorName: string;
  platformName: string;
  platformLogoUrl: string;
};

export async function generateCertificate(data: CertificateData): Promise<string> {
  const certificateId = `CERT-${data.enrollmentId.toUpperCase()}`;
  const verifyUrl = `https://yourplatform.com/verify/${certificateId}`;

  const html = Mustache.render(CERTIFICATE_HTML, {
    ...data,
    certificateId,
    verifyUrl,
  });

  const pdfRes = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      filename: `certificate-${data.enrollmentId}.pdf`,
      store: true,
      options: { format: "A4", landscape: true },
    }),
  });

  const { document_url, document_id } = await pdfRes.json();

  // Persist the certificate URL against the enrollment record
  await db.enrollments.update(data.enrollmentId, {
    certificatePdfId: document_id,
    certificatePdfUrl: document_url,
    certificateIssuedAt: new Date(),
  });

  return document_url;
}

Academic transcript (Python, multi-page)

A multi-page transcript with a courses table per semester, a GPA row at the foot of each table, and a cumulative GPA block at the end. The CSS uses @page { size: A4 } so every page carries the correct dimensions, and page-break-inside: avoid on each semester block keeps a semester from splitting across pages.

Python
# records/transcript.py
# Generates a multi-page academic transcript for a student at term end.
import os
import httpx
from datetime import date

PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf"

TRANSCRIPT_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
  @page {{ size: A4; margin: 20mm 18mm; }}
  body {{ font-family: 'Times New Roman', serif; color: #111; font-size: 12px; line-height: 1.6; }}
  .header {{ text-align: center; border-bottom: 2px solid #111;
             padding-bottom: 16px; margin-bottom: 24px; }}
  .header h1 {{ font-size: 22px; font-weight: 700; margin-bottom: 4px; }}
  .header p {{ font-size: 12px; color: #444; margin: 2px 0; }}
  .student-info {{ display: grid; grid-template-columns: 1fr 1fr;
                   gap: 6px 24px; margin-bottom: 28px; font-size: 12px; }}
  .student-info .field {{ display: flex; gap: 8px; }}
  .student-info .field span:first-child {{ font-weight: 600; min-width: 120px; }}
  .semester-block {{ margin-bottom: 28px; page-break-inside: avoid; }}
  .semester-heading {{ font-size: 13px; font-weight: 700; text-transform: uppercase;
                       letter-spacing: 0.06em; border-bottom: 1px solid #bbb;
                       padding-bottom: 4px; margin-bottom: 10px; }}
  .courses-table {{ width: 100%; border-collapse: collapse; font-size: 12px; }}
  .courses-table th {{ text-align: left; font-weight: 600; font-size: 11px;
                       text-transform: uppercase; letter-spacing: 0.05em;
                       color: #444; padding: 5px 0; border-bottom: 1px solid #ddd; }}
  .courses-table td {{ padding: 6px 0; border-bottom: 1px solid #f0f0f0; }}
  .courses-table .credits {{ text-align: right; }}
  .courses-table .grade {{ text-align: right; font-weight: 600; }}
  .gpa-row {{ font-weight: 700; border-top: 2px solid #111 !important;
              border-bottom: none !important; }}
  .cumulative {{ margin-top: 32px; padding: 16px 20px;
                 border: 1px solid #bbb; font-size: 13px; }}
  .cumulative span {{ font-weight: 700; font-size: 15px; }}
  .official-notice {{ margin-top: 40px; font-size: 10px; color: #666;
                      text-align: center; border-top: 1px solid #ddd;
                      padding-top: 12px; }}
</style>
</head>
<body>
  <div class="header">
    <h1>{institution_name}</h1>
    <p>{institution_address}</p>
    <p>Official Academic Transcript</p>
  </div>

  <div class="student-info">
    <div class="field"><span>Student Name:</span><span>{student_name}</span></div>
    <div class="field"><span>Student ID:</span><span>{student_id}</span></div>
    <div class="field"><span>Programme:</span><span>{programme}</span></div>
    <div class="field"><span>Date of Issue:</span><span>{issue_date}</span></div>
    <div class="field"><span>Enrollment Status:</span><span>{enrollment_status}</span></div>
  </div>

  {semester_blocks}

  <div class="cumulative">
    Cumulative GPA: <span>{cumulative_gpa}</span> &nbsp;|&nbsp;
    Total Credits Earned: <span>{total_credits}</span>
  </div>

  <div class="official-notice">
    This is an official document issued by {institution_name}. Any alteration renders it void.
    Issued on {issue_date}.
  </div>
</body>
</html>"""

SEMESTER_BLOCK_TEMPLATE = """
<div class="semester-block">
  <div class="semester-heading">{semester_label}</div>
  <table class="courses-table">
    <thead>
      <tr>
        <th style="width:10%">Code</th>
        <th style="width:50%">Course Title</th>
        <th class="credits">Credits</th>
        <th class="grade">Grade</th>
        <th class="grade">Points</th>
      </tr>
    </thead>
    <tbody>
      {course_rows}
      <tr class="gpa-row">
        <td colspan="2">Semester GPA</td>
        <td class="credits">{total_credits}</td>
        <td class="grade">{semester_gpa}</td>
        <td></td>
      </tr>
    </tbody>
  </table>
</div>"""

COURSE_ROW_TEMPLATE = (
    '<tr><td>{code}</td><td>{title}</td>'
    '<td class="credits">{credits}</td>'
    '<td class="grade">{grade}</td>'
    '<td class="grade">{points:.2f}</td></tr>'
)


def build_semester_block(semester: dict) -> str:
    rows = "".join(
        COURSE_ROW_TEMPLATE.format(**c) for c in semester["courses"]
    )
    return SEMESTER_BLOCK_TEMPLATE.format(
        semester_label=semester["label"],
        course_rows=rows,
        total_credits=semester["total_credits"],
        semester_gpa=f'{semester["gpa"]:.2f}',
    )


def generate_transcript(student: dict, semesters: list[dict]) -> str:
    """
    Called at term end or on demand from the student records system.
    Returns the stored PDF URL for the student portal.
    """
    semester_blocks = "".join(build_semester_block(s) for s in semesters)
    total_credits = sum(s["total_credits"] for s in semesters)

    # Weighted cumulative GPA
    weighted = sum(s["gpa"] * s["total_credits"] for s in semesters)
    cumulative_gpa = f"{(weighted / total_credits):.2f}" if total_credits else "0.00"

    html = TRANSCRIPT_TEMPLATE.format(
        institution_name="Meridian University",
        institution_address="12 Academic Way, Knowledge City, MH 400001",
        student_name=student["full_name"],
        student_id=student["student_id"],
        programme=student["programme"],
        enrollment_status=student["status"],
        issue_date=date.today().strftime("%d %B %Y"),
        semester_blocks=semester_blocks,
        cumulative_gpa=cumulative_gpa,
        total_credits=total_credits,
    )

    response = httpx.post(
        PDFPIPE_URL,
        headers={
            "Authorization": f"Bearer {PDFPIPE_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "html": html,
            "filename": f"transcript-{student['student_id']}.pdf",
            "store": True,
            "options": {"format": "A4"},
        },
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()

    db.students.update(
        student["student_id"],
        {"transcript_pdf_url": data["document_url"], "transcript_pdf_id": data["document_id"]},
    )

    return data["document_url"]

LMS webhook handler (TypeScript)

When your LMS fires a course.completed event, verify the HMAC signature, build the certificate HTML, call the PDF API with store: true, then email the learner the document_url. The example uses the Teachable and Thinkific-compatible webhook payload shape.

TypeScript
// webhooks/lms-handler.ts
// Handles a course.completed event from a webhook-enabled LMS
// (compatible with Teachable / Thinkific-style payloads)
import crypto from "crypto";

type LMSCourseCompletedEvent = {
  event: "course.completed";
  data: {
    enrollment_id: string;
    learner_id: string;
    learner_name: string;
    learner_email: string;
    course_id: string;
    course_name: string;
    completed_at: string;   // ISO 8601
    instructor_name: string;
  };
};

function verifyLMSSignature(rawBody: string, signature: string): boolean {
  const expected = crypto
    .createHmac("sha256", process.env.LMS_WEBHOOK_SECRET!)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

export async function handleLMSWebhook(
  rawBody: string,
  signatureHeader: string,
): Promise<void> {
  if (!verifyLMSSignature(rawBody, signatureHeader)) {
    throw new Error("Invalid webhook signature");
  }

  const payload: LMSCourseCompletedEvent = JSON.parse(rawBody);
  if (payload.event !== "course.completed") return;

  const { data } = payload;

  const completionDate = new Date(data.completed_at).toLocaleDateString("en-US", {
    month: "long", day: "numeric", year: "numeric",
  });

  const certificateHtml = buildCertificateHtml({
    learnerName: data.learner_name,
    courseName: data.course_name,
    completionDate,
    instructorName: data.instructor_name,
    enrollmentId: data.enrollment_id,
    platformName: "YourPlatform",
    platformLogoUrl: "https://yourplatform.com/logo.png",
  });

  const pdfRes = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html: certificateHtml,
      filename: `certificate-${data.enrollment_id}.pdf`,
      store: true,
      options: { format: "A4", landscape: true },
    }),
  });

  const { document_url, document_id } = await pdfRes.json();

  // Persist against the enrollment record
  await db.enrollments.update(data.enrollment_id, {
    certificatePdfId: document_id,
    certificatePdfUrl: document_url,
    certificateIssuedAt: new Date(),
  });

  // Email the certificate link to the learner
  await sendEmail({
    to: data.learner_email,
    subject: `Your certificate for "${data.course_name}" is ready`,
    body: `
Hi ${data.learner_name},

Congratulations on completing "${data.course_name}"!
Your certificate is available at the link below.

Download: ${document_url}

You can share this link directly or add it to your LinkedIn profile.
    `,
  });
}

Batch end-of-term certificates (Python asyncio)

At term close, fetch all students who completed courses this term, generate every certificate concurrently with asyncio.gather, persist each URL, and email the download link. Failures are logged per enrollment so one bad record does not stop the rest of the batch.

Python
# scripts/batch_term_certificates.py
# Generates certificates for all students who completed courses this term.
# Run at term close or triggered by your student records system.
import asyncio
import os
import httpx
from datetime import date

PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf"

COMPLETION_DATE = date.today().strftime("%B %d, %Y")


async def generate_one(
    client: httpx.AsyncClient,
    enrollment: dict,
) -> tuple[str, dict]:
    html = build_certificate_html(
        learner_name=enrollment["learner_name"],
        course_name=enrollment["course_name"],
        completion_date=COMPLETION_DATE,
        instructor_name=enrollment["instructor_name"],
        enrollment_id=enrollment["id"],
        platform_name="YourPlatform",
        platform_logo_url="https://yourplatform.com/logo.png",
    )

    response = await client.post(
        PDFPIPE_URL,
        headers={
            "Authorization": f"Bearer {PDFPIPE_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "html": html,
            "filename": f"certificate-{enrollment['id']}.pdf",
            "store": True,
            "options": {"format": "A4", "landscape": True},
        },
        timeout=30,
    )
    response.raise_for_status()
    return enrollment["id"], response.json()


async def batch_generate_certificates(enrollments: list[dict]) -> None:
    """
    Fetches all students who completed courses this term and generates
    their certificates concurrently, then emails each download link.
    """
    async with httpx.AsyncClient() as client:
        tasks = [generate_one(client, e) for e in enrollments]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    for enrollment, result in zip(enrollments, results):
        if isinstance(result, Exception):
            print(f"Failed for {enrollment['id']}: {result}")
            continue

        enrollment_id, data = result

        # Persist the certificate URL
        db.enrollments.update(
            enrollment_id,
            {
                "certificate_pdf_url": data["document_url"],
                "certificate_pdf_id": data["document_id"],
                "certificate_issued_at": date.today().isoformat(),
            },
        )

        # Email the learner
        send_email(
            to=enrollment["learner_email"],
            subject=f'Your certificate for "{enrollment["course_name"]}" is ready',
            body=(
                f'Hi {enrollment["learner_name"]},\n\n'
                f'Your certificate is ready: {data["document_url"]}\n\n'
                "Congratulations on completing this term."
            ),
        )
        print(f"Certificate issued: {enrollment_id} -> {data['document_url']}")


# Entry point: called from your end-of-term job or CI pipeline
if __name__ == "__main__":
    term_completions = db.enrollments.find_completed_this_term()
    asyncio.run(batch_generate_certificates(term_completions))

Plans

PlanDocuments/moStoragePrice
Hobby5001 dayFree
Starter3,00030 days$19/mo
Growth15,000365 days$49/mo
Scale50,0002 years$149/mo
Business100,0002 years$499/mo

Need more than 100,000 documents per month? Contact us for enterprise pricing.

Try it free →