Use case
HR Documents PDF API
Generate offer letters, employment agreements, and onboarding packets automatically when your ATS or HRIS fires an event. Each document is stored and returned as a stable download link your candidate or employee can access from any device.
Offer letter HTML template
A Mustache template covering every field an offer letter needs: company logo, candidate name, role and title, start date, salary, benefits summary, reporting manager, acceptance deadline, and a dual signature block with a date field for each party. Render it server-side from your ATS data and POST the HTML to the PDF API.
<!-- templates/offer-letter.html -->
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', sans-serif; color: #1a1a1a; margin: 0; padding: 56px 64px; }
.letterhead { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 48px; }
.logo img { height: 40px; }
.logo-text { font-size: 22px; font-weight: 700; color: #d23a1d; }
.date-block { font-size: 13px; color: #666; text-align: right; }
h1 { font-size: 22px; font-weight: 600; color: #1a1a1a; margin-bottom: 24px; }
p { font-size: 14px; line-height: 1.7; color: #333; margin-bottom: 16px; }
.details-table { width: 100%; border-collapse: collapse; margin: 32px 0; }
.details-table th { text-align: left; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.07em; color: #999; padding: 8px 0; border-bottom: 1px solid #e5e5e5; }
.details-table td { font-size: 14px; padding: 12px 0; border-bottom: 1px solid #f0f0f0; }
.details-table td:first-child { color: #666; width: 220px; }
.details-table td:last-child { font-weight: 500; color: #1a1a1a; }
.benefits { background: #f9f9f9; border-left: 3px solid #d23a1d; padding: 20px 24px; margin: 32px 0; }
.benefits h3 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.07em; color: #999; margin-bottom: 12px; }
.benefits ul { margin: 0; padding-left: 18px; }
.benefits li { font-size: 14px; color: #333; margin-bottom: 6px; }
.deadline { font-size: 13px; color: #d23a1d; font-weight: 600; margin-top: 32px; }
.signature-block { margin-top: 64px; display: flex; gap: 80px; }
.sig-col { flex: 1; }
.sig-line { border-bottom: 1px solid #1a1a1a; margin-bottom: 6px; height: 32px; }
.sig-label { font-size: 12px; color: #999; }
.sig-name { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-top: 4px; }
@page { margin: 0; size: A4; }
</style>
</head>
<body>
<div class="letterhead">
<div class="logo-text">{{companyName}}</div>
<div class="date-block">{{letterDate}}</div>
</div>
<p>Dear {{candidateName}},</p>
<h1>Offer of Employment: {{jobTitle}}</h1>
<p>
We are delighted to extend this offer of employment at <strong>{{companyName}}</strong>.
This letter confirms the terms of your employment as outlined below.
</p>
<table class="details-table">
<thead>
<tr><th>Detail</th><th>Terms</th></tr>
</thead>
<tbody>
<tr><td>Position</td><td>{{jobTitle}}</td></tr>
<tr><td>Department</td><td>{{department}}</td></tr>
<tr><td>Start Date</td><td>{{startDate}}</td></tr>
<tr><td>Annual Salary</td><td>{{salary}}</td></tr>
<tr><td>Employment Type</td><td>{{employmentType}}</td></tr>
<tr><td>Reporting Manager</td><td>{{reportingManager}}</td></tr>
<tr><td>Work Location</td><td>{{workLocation}}</td></tr>
</tbody>
</table>
<div class="benefits">
<h3>Benefits Summary</h3>
<ul>
{{#benefits}}
<li>{{.}}</li>
{{/benefits}}
</ul>
</div>
<p>
This offer is contingent on successful completion of a background check and your
execution of the enclosed employment agreement, which includes a mutual NDA and
IP assignment clause.
</p>
<p class="deadline">
Please confirm your acceptance by <strong>{{acceptanceDeadline}}</strong> by signing below
and returning a copy to {{hrEmail}}.
</p>
<div class="signature-block">
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Authorised Signatory</div>
<div class="sig-name">{{signatoryName}}, {{signatoryTitle}}</div>
<div class="sig-label">Date: _______________</div>
</div>
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Candidate Signature</div>
<div class="sig-name">{{candidateName}}</div>
<div class="sig-label">Date: _______________</div>
</div>
</div>
</body>
</html>ATS webhook handler (offer letter PDF generation)
Listen for the offer.extended event from Greenhouse, Workday, or any ATS that supports webhooks. Fill the template with the candidate data, call the PDF API with store: true, write the returned document URL back to the ATS record, then email the candidate a direct download link.
// handlers/offer-extended.ts
// Triggered when Greenhouse / Workday fires an "offer.extended" event
import Mustache from "mustache";
import { readFileSync } from "fs";
const template = readFileSync("./templates/offer-letter.html", "utf-8");
type OfferExtendedPayload = {
candidateName: string;
candidateEmail: string;
jobTitle: string;
department: string;
startDate: string;
salary: string;
employmentType: string;
reportingManager: string;
workLocation: string;
benefits: string[];
acceptanceDeadline: string;
applicationId: string;
};
export async function handleOfferExtended(payload: OfferExtendedPayload): Promise<void> {
const html = Mustache.render(template, {
companyName: "Acme Corp",
letterDate: new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
signatoryName: "Sarah Okonkwo",
signatoryTitle: "VP of People",
hrEmail: "[email protected]",
...payload,
});
// Generate the offer letter PDF and store it
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: `offer-${payload.applicationId}.pdf`,
store: true,
options: { format: "A4" },
}),
});
const { document_url, document_id, document_expires } = await pdfRes.json();
// Write the document URL back to your ATS / HRIS record
await db.offerLetters.upsert({
applicationId: payload.applicationId,
candidateEmail: payload.candidateEmail,
pdfId: document_id,
pdfUrl: document_url,
expiresAt: new Date(document_expires),
status: "sent",
});
// Send the candidate a download link
await sendEmail({
to: payload.candidateEmail,
subject: `Your offer letter from Acme Corp: ${payload.jobTitle}`,
body: `
Hi ${payload.candidateName},
Please find your offer letter at the link below. It is available to download
until ${payload.acceptanceDeadline}.
Download: ${document_url}
Please sign and return a copy to [email protected] by ${payload.acceptanceDeadline}.
`,
});
}Employment agreement PDF (Python, HRIS new hire event)
When your HRIS creates a new hire record, generate the full employment agreement containing the NDA and IP assignment clauses. Using store: true on Growth or higher gives the document a 365-day or longer retention window, which is appropriate for legally significant employment documents.
# hris_hooks/new_hire.py
# Triggered when a new hire is created in your HRIS (Workday, BambooHR, Rippling)
import os
import httpx
from datetime import date
PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf"
AGREEMENT_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', sans-serif; color: #1a1a1a; padding: 56px 64px; }
h1 { font-size: 20px; font-weight: 700; margin-bottom: 32px; }
h2 { font-size: 15px; font-weight: 600; margin-top: 32px; margin-bottom: 12px;
text-transform: uppercase; letter-spacing: 0.06em; }
p, li { font-size: 13px; line-height: 1.75; color: #333; }
.parties {{ margin-bottom: 32px; background: #f9f9f9; padding: 20px 24px; }}
.parties p {{ margin: 4px 0; }}
.signature-block {{ margin-top: 64px; display: flex; gap: 80px; }}
.sig-col {{ flex: 1; }}
.sig-line {{ border-bottom: 1px solid #1a1a1a; height: 36px; }}
.sig-label {{ font-size: 12px; color: #999; margin-top: 6px; }}
@page {{ margin: 0; size: A4; }}
</style>
</head>
<body>
<h1>Employment Agreement, Mutual NDA, and IP Assignment</h1>
<div class="parties">
<p><strong>Employer:</strong> {company_name}</p>
<p><strong>Employee:</strong> {employee_name}</p>
<p><strong>Role:</strong> {job_title}</p>
<p><strong>Start Date:</strong> {start_date}</p>
<p><strong>Effective Date:</strong> {effective_date}</p>
</div>
<h2>1. Mutual Non-Disclosure</h2>
<p>
Each party agrees to hold in strict confidence, and not to disclose or use for any purpose
outside this employment relationship, any Confidential Information of the other party.
"Confidential Information" means all technical, business, or other information that is
marked confidential or would reasonably be understood to be confidential given the nature
of the information and circumstances of disclosure.
</p>
<h2>2. IP Assignment</h2>
<p>
Employee agrees that all inventions, works, and deliverables conceived, developed, or
reduced to practice in the scope of employment are the sole property of {company_name}
and hereby assigns all right, title, and interest therein to the Employer.
</p>
<h2>3. Non-Solicitation</h2>
<p>
During employment and for twelve (12) months thereafter, Employee agrees not to solicit
any employees or customers of {company_name} for a competing business.
</p>
<div class="signature-block">
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Employee Signature</div>
<div class="sig-label">{employee_name} — Date: _______________</div>
</div>
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Authorised Signatory</div>
<div class="sig-label">{signatory_name}, {company_name} — Date: _______________</div>
</div>
</div>
</body>
</html>
"""
def generate_employment_agreement(hire: dict) -> str:
"""
Called by the HRIS webhook when a new hire record is created.
Returns the stored PDF URL for the employee's onboarding portal.
"""
html = AGREEMENT_TEMPLATE.format(
company_name="Acme Corp",
employee_name=hire["full_name"],
job_title=hire["job_title"],
start_date=hire["start_date"],
effective_date=date.today().strftime("%B %d, %Y"),
signatory_name="Sarah Okonkwo",
)
response = httpx.post(
PDFPIPE_URL,
headers={
"Authorization": f"Bearer {PDFPIPE_KEY}",
"Content-Type": "application/json",
},
json={
"html": html,
"filename": f"employment-agreement-{hire['employee_id']}.pdf",
"store": True, # stored with a long expiry on Growth+ plans
"options": {"format": "A4"},
},
timeout=30,
)
response.raise_for_status()
data = response.json()
# Persist the URL in the HRIS or your own employee record store
db.employees.update(
hire["employee_id"],
{"agreement_pdf_url": data["document_url"], "agreement_pdf_id": data["document_id"]},
)
return data["document_url"]NDA generation for contractors
Contractor NDAs follow the same render-and-store pattern. The only difference is the template: a contractor NDA omits the salary and benefits fields but keeps the mutual confidentiality and IP assignment clauses.
When a large cohort of contractors or new hires starts on the same day, use the batch endpoint to pre-generate all NDAs in a single HTTP call rather than looping over individual requests. The batch endpoint accepts up to 50 documents per call, returns results in input order, and charges the same per-document rate as the single-document endpoint.
// scripts/batch-nda.ts
// Pre-generate NDAs for a class of contractors or new hires joining on the same day
import Mustache from "mustache";
import { readFileSync } from "fs";
const ndaTemplate = readFileSync("./templates/contractor-nda.html", "utf-8");
type Contractor = {
id: string;
name: string;
email: string;
role: string;
startDate: string;
};
export async function batchGenerateNDAs(contractors: Contractor[]): Promise<void> {
const requests = contractors.map((c) => ({
html: Mustache.render(ndaTemplate, {
contractorName: c.name,
contractorRole: c.role,
startDate: c.startDate,
companyName: "Acme Corp",
effectiveDate: new Date().toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
}),
}),
filename: `nda-contractor-${c.id}.pdf`,
store: true,
options: { format: "A4" },
}));
// Use the batch endpoint — one HTTP call, up to 50 documents
const batchRes = await fetch("https://api.pdfpipe.xyz/v1/pdf/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ documents: requests }),
});
const { results } = await batchRes.json();
// results is an array aligned with the input order
for (let i = 0; i < contractors.length; i++) {
const contractor = contractors[i];
const { document_url, document_id } = results[i];
await db.contractorNDAs.create({
contractorId: contractor.id,
pdfId: document_id,
pdfUrl: document_url,
});
// Email each contractor their NDA link
await sendEmail({
to: contractor.email,
subject: "Please review and sign your NDA before your start date",
body: `Hi ${contractor.name}, your NDA is ready: ${document_url}`,
});
}
}
// Example: called from your onboarding cron the night before a cohort start date
const tomorrowCohort = await db.contractors.findByStartDate(tomorrowDateString);
await batchGenerateNDAs(tomorrowCohort);Plans
| Plan | Documents/mo | Storage | Price |
|---|---|---|---|
| Hobby | 500 | 1 day | Free |
| Starter | 3,000 | 30 days | $19/mo |
| Growth | 15,000 | 365 days | $49/mo |
| Scale | 50,000 | 2 years | $149/mo |
| Business | 100,000 | 2 years | $499/mo |
Need more than 100,000 documents per month? Contact us for enterprise pricing.