Use case
Rental Agreement PDF API
Proptech platforms, property management SaaS, and real estate portals need to generate lease agreements, renewal notices, move-in checklists, and subletting agreements at scale. Each document must carry consistent legal typography, proper signature lines, and a stable URL the tenant can access from their portal indefinitely.
Residential lease from a template (TypeScript)
A full residential lease template using Georgia serif body text, 1-inch margins via CSS @page, a running property address header, page numbers in the footer via CSS counters, a key-terms summary table, and a signature block that never splits across pages. Call with store: true to receive a permanent download URL you can embed in the tenant portal.
// handlers/generate-lease.ts
import Mustache from "mustache";
// templates/residential-lease.html (excerpt)
const LEASE_TEMPLATE_HTML = `<!DOCTYPE html>
<html>
<head>
<style>
@page {
margin: 1in;
size: letter;
/* Page numbers in the footer on every page */
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-family: Georgia, serif;
font-size: 10px;
color: #555;
}
/* Property address in the header after the first page */
@top-right {
content: "{{propertyAddress}}";
font-family: Georgia, serif;
font-size: 9px;
color: #888;
}
}
/* Suppress running header on the cover page */
@page :first {
@top-right { content: none; }
}
body {
font-family: Georgia, serif;
font-size: 12pt;
line-height: 1.6;
color: #1a1a1a;
margin: 0;
}
.document-title {
font-size: 16pt;
font-weight: bold;
text-align: center;
margin-bottom: 6px;
}
.document-subtitle {
text-align: center;
font-size: 10pt;
color: #555;
margin-bottom: 40px;
}
.parties {
background: #f7f7f5;
border-left: 3px solid #1a1a1a;
padding: 18px 22px;
margin-bottom: 32px;
}
.parties p { margin: 6px 0; font-size: 11pt; }
h2 {
font-size: 11pt;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.07em;
margin-top: 28px;
margin-bottom: 10px;
page-break-after: avoid;
}
p { margin-bottom: 14px; }
.key-terms {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 11pt;
}
.key-terms th {
text-align: left;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid #1a1a1a;
padding: 6px 0;
}
.key-terms td {
padding: 10px 0;
border-bottom: 1px solid #e0e0e0;
vertical-align: top;
}
.key-terms td:first-child {
width: 45%;
color: #555;
font-size: 10pt;
}
/* Signature block: never split across pages */
.signature-block {
margin-top: 56px;
display: flex;
gap: 80px;
page-break-inside: avoid;
}
.sig-col { flex: 1; }
/* Signature line with generous vertical space above */
.sig-line {
border-bottom: 1px solid #1a1a1a;
height: 40px;
margin-bottom: 6px;
}
.sig-label { font-size: 9pt; color: #555; }
.sig-name { font-size: 10pt; font-weight: bold; margin-top: 4px; }
</style>
</head>
<body>
<div class="document-title">Residential Lease Agreement</div>
<div class="document-subtitle">
This agreement is entered into on {{leaseStartDate}}
</div>
<div class="parties">
<p><strong>Landlord:</strong> {{landlordName}}</p>
<p><strong>Tenant(s):</strong> {{tenantName}}</p>
<p><strong>Property:</strong> {{propertyAddress}}</p>
</div>
<h2>1. Key Terms</h2>
<table class="key-terms">
<thead>
<tr><th>Term</th><th>Detail</th></tr>
</thead>
<tbody>
<tr><td>Monthly Rent</td><td><strong>{{monthlyRent}}</strong></td></tr>
<tr><td>Lease Start</td><td>{{leaseStartDate}}</td></tr>
<tr><td>Lease End</td><td>{{leaseEndDate}}</td></tr>
<tr><td>Security Deposit</td><td>{{securityDeposit}}</td></tr>
<tr><td>Rent Due</td><td>1st of each month</td></tr>
<tr><td>Late Fee</td><td>{{lateFee}} after a {{gracePeriod}}-day grace period</td></tr>
</tbody>
</table>
<h2>2. Premises</h2>
<p>
Landlord hereby leases to Tenant the residential property located at
<strong>{{propertyAddress}}</strong> (the "Premises"), for use as a private
residence only. Tenant shall not sublet the Premises or any part thereof
without the prior written consent of Landlord.
</p>
<h2>3. Rent</h2>
<p>
Tenant agrees to pay <strong>{{monthlyRent}}</strong> per month, due on the
first day of each calendar month. Rent shall be paid by
{{paymentMethod}}. A late fee of {{lateFee}} will be assessed for any payment
received more than {{gracePeriod}} days after the due date.
</p>
<h2>4. Security Deposit</h2>
<p>
Upon execution of this Agreement, Tenant shall deposit the sum of
<strong>{{securityDeposit}}</strong> as a security deposit. The deposit
will be held in a separate account and returned within 21 days of move-out,
less any deductions for unpaid rent or damages beyond normal wear and tear.
</p>
<h2>5. Maintenance and Repairs</h2>
<p>
Tenant shall keep the Premises in a clean and sanitary condition and shall
promptly notify Landlord of any damage or needed repairs. Tenant is
responsible for minor maintenance (e.g., replacing light bulbs, keeping
drains clear). Landlord is responsible for structural repairs and maintaining
habitable conditions.
</p>
<h2>6. Entry by Landlord</h2>
<p>
Landlord shall provide at least 24 hours' written notice before entering the
Premises for non-emergency inspections or repairs, except in the case of an
emergency that poses an immediate threat to health or safety.
</p>
<h2>7. Termination</h2>
<p>
Either party may terminate this Agreement at the end of the lease term by
providing at least 30 days' written notice. Failure to vacate by the stated
end date shall constitute a holdover tenancy subject to applicable law.
</p>
<div class="signature-block">
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Landlord Signature</div>
<div class="sig-name">{{landlordName}}</div>
<div class="sig-label">Date: _______________</div>
</div>
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Tenant Signature</div>
<div class="sig-name">{{tenantName}}</div>
<div class="sig-label">Date: _______________</div>
</div>
</div>
</body>
</html>`;
type LeaseData = {
tenantName: string;
propertyAddress: string;
monthlyRent: string; // e.g. "$2,400/month"
securityDeposit: string; // e.g. "$4,800"
leaseStartDate: string; // e.g. "July 1, 2026"
leaseEndDate: string; // e.g. "June 30, 2027"
landlordName: string;
lateFee: string; // e.g. "$75"
gracePeriod: number; // days
paymentMethod: string; // e.g. "bank transfer to account on file"
leaseId: string; // used for the filename
};
export async function generateLease(data: LeaseData): Promise<string> {
const html = Mustache.render(LEASE_TEMPLATE_HTML, data);
const res = 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: `lease-${data.leaseId}.pdf`,
store: true, // get a permanent URL for the tenant portal
options: { format: "Letter" },
}),
});
if (!res.ok) {
throw new Error(`PDF generation failed: ${res.status}`);
}
const { document_url } = await res.json();
return document_url;
}Webhook: auto-generate on application approval (Python)
A FastAPI endpoint that receives an application.approved event from your property management system, renders the lease HTML, calls the PDF API with store: true, and emails the tenant a download link. The tenant can sign and return the document before move-in day, with no manual step from the leasing team.
# webhooks/lease_application_handler.py
# Fires when a lease application is approved in your property management system.
# Generates the lease PDF and sends the tenant a download link.
import os
import httpx
from fastapi import FastAPI, Request
from datetime import date, timedelta
app = FastAPI()
PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf"
SENDGRID_KEY = os.environ["SENDGRID_API_KEY"]
LEASE_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
@page {{
margin: 1in; size: letter;
@bottom-center {{
content: "Page " counter(page) " of " counter(pages);
font-family: Georgia, serif; font-size: 10px; color: #555;
}}
}}
body {{
font-family: Georgia, serif; font-size: 12pt;
line-height: 1.6; color: #1a1a1a; margin: 0;
}}
.document-title {{ font-size: 16pt; font-weight: bold; text-align: center; margin-bottom: 6px; }}
.parties {{ background: #f7f7f5; border-left: 3px solid #1a1a1a; padding: 18px 22px; margin-bottom: 28px; }}
.parties p {{ margin: 6px 0; font-size: 11pt; }}
h2 {{ font-size: 11pt; font-weight: bold; text-transform: uppercase;
letter-spacing: 0.07em; margin-top: 28px; margin-bottom: 10px;
page-break-after: avoid; }}
p {{ margin-bottom: 14px; }}
.signature-block {{ margin-top: 56px; display: flex; gap: 80px; page-break-inside: avoid; }}
.sig-col {{ flex: 1; }}
.sig-line {{ border-bottom: 1px solid #1a1a1a; height: 40px; margin-bottom: 6px; }}
.sig-label {{ font-size: 9pt; color: #555; }}
.sig-name {{ font-size: 10pt; font-weight: bold; margin-top: 4px; }}
</style>
</head>
<body>
<div class="document-title">Residential Lease Agreement</div>
<div class="parties">
<p><strong>Landlord:</strong> {landlord_name}</p>
<p><strong>Tenant:</strong> {tenant_name}</p>
<p><strong>Property:</strong> {property_address}</p>
<p><strong>Term:</strong> {lease_start} through {lease_end}</p>
<p><strong>Monthly Rent:</strong> {monthly_rent}</p>
</div>
<h2>1. Premises</h2>
<p>
Landlord leases to Tenant the property at <strong>{property_address}</strong>
for residential use only. No subletting without prior written consent.
</p>
<h2>2. Rent</h2>
<p>
Tenant shall pay <strong>{monthly_rent}</strong> per month, due on the first
of each calendar month. A late fee of {late_fee} applies after a 5-day grace period.
</p>
<h2>3. Security Deposit</h2>
<p>
A deposit of {security_deposit} is due upon signing. It will be returned within
21 days of move-out, less lawful deductions.
</p>
<div class="signature-block">
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Landlord Signature</div>
<div class="sig-name">{landlord_name}</div>
<div class="sig-label">Date: _______________</div>
</div>
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Tenant Signature</div>
<div class="sig-name">{tenant_name}</div>
<div class="sig-label">Date: _______________</div>
</div>
</div>
</body>
</html>"""
@app.post("/webhooks/application-approved")
async def application_approved(request: Request):
payload = await request.json()
event_type = payload.get("event")
if event_type != "application.approved":
return {"status": "ignored"}
application = payload["data"]
application_id = application["id"]
# Build lease dates: 12-month term starting next month
start = date.today().replace(day=1) + timedelta(days=32)
start = start.replace(day=1)
end = start.replace(year=start.year + 1) - timedelta(days=1)
html = LEASE_TEMPLATE.format(
landlord_name = application["landlord_name"],
tenant_name = application["applicant_name"],
property_address = application["property_address"],
lease_start = start.strftime("%B %d, %Y"),
lease_end = end.strftime("%B %d, %Y"),
monthly_rent = application["monthly_rent"],
security_deposit = application["security_deposit"],
late_fee = application.get("late_fee", "$75"),
)
# Generate the PDF and store it for a permanent URL
pdf_response = httpx.post(
PDFPIPE_URL,
headers={
"Authorization": f"Bearer {PDFPIPE_KEY}",
"Content-Type": "application/json",
},
json={
"html": html,
"filename": f"lease-{application_id}.pdf",
"store": True,
"options": {"format": "Letter"},
},
timeout=30,
)
pdf_response.raise_for_status()
pdf_data = pdf_response.json()
document_url = pdf_data["document_url"]
# Send the tenant a download link via email
httpx.post(
"https://api.sendgrid.com/v3/mail/send",
headers={"Authorization": f"Bearer {SENDGRID_KEY}"},
json={
"to": [{"email": application["applicant_email"]}],
"from": {"email": "[email protected]"},
"subject": "Your lease agreement is ready to sign",
"content": [{
"type": "text/plain",
"value": (
f"Hi {application['applicant_name']},\n\n"
f"Your lease for {application['property_address']} is ready.\n"
f"Download and sign here: {document_url}\n\n"
"Please return the signed copy within 48 hours to secure your unit."
),
}],
},
timeout=10,
)
return {
"status": "ok",
"application_id": application_id,
"document_url": document_url,
"document_id": pdf_data["document_id"],
}Batch renewal notices with a cron job (Python)
A daily cron script that queries all leases expiring within 30 days, builds a personalised renewal notice for each tenant, and submits them as a single batch call. The batch endpoint handles up to 50 documents per request, so for larger portfolios the script chunks the list and processes each chunk in one call rather than looping over individual requests. Each result carries a stable document_url you can write back to the lease record so the tenant portal reflects the latest notice immediately.
# scripts/batch_renewal_notices.py
# Cron job: runs daily, finds all leases expiring in the next 30 days,
# and generates renewal notice PDFs in one batch call.
#
# Schedule (cron): 0 8 * * * (every day at 08:00)
import os
import asyncio
import httpx
from datetime import date, timedelta
PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
BATCH_URL = "https://api.pdfpipe.xyz/v1/pdf/batch"
RENEWAL_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
@page {{
margin: 1in; size: letter;
@bottom-center {{
content: counter(page) " of " counter(pages);
font-family: Georgia, serif; font-size: 10px; color: #555;
}}
}}
body {{
font-family: Georgia, serif; font-size: 12pt;
line-height: 1.6; color: #1a1a1a; margin: 0;
}}
.letterhead {{
border-bottom: 2px solid #1a1a1a; margin-bottom: 36px;
padding-bottom: 16px; display: flex; justify-content: space-between;
}}
.company-name {{ font-size: 16pt; font-weight: bold; }}
.notice-ref {{
font-size: 9pt; text-transform: uppercase;
letter-spacing: 0.08em; color: #555; margin-bottom: 32px;
}}
h1 {{ font-size: 14pt; font-weight: bold; margin-bottom: 20px; }}
p {{ margin-bottom: 14px; }}
.highlight {{ background: #f7f7f5; border-left: 3px solid #1a1a1a; padding: 14px 20px; margin: 20px 0; }}
.highlight p {{ margin: 4px 0; }}
.signature-block {{ margin-top: 48px; page-break-inside: avoid; }}
.sig-line {{ border-bottom: 1px solid #1a1a1a; height: 40px; width: 280px; margin-bottom: 6px; }}
.sig-label {{ font-size: 9pt; color: #555; }}
.sig-name {{ font-size: 10pt; font-weight: bold; margin-top: 4px; }}
</style>
</head>
<body>
<div class="letterhead">
<div class="company-name">{company_name}</div>
<div style="font-size: 10pt; color: #555; text-align: right;">{notice_date}</div>
</div>
<div class="notice-ref">Lease Renewal Notice • Unit {unit_id}</div>
<p>
{tenant_name}<br />
{property_address}
</p>
<h1>Notice of Lease Renewal Offer</h1>
<p>Dear {tenant_salutation},</p>
<p>
Your current lease for the above property expires on
<strong>{current_lease_end}</strong>. We would like to offer you the opportunity
to renew your tenancy under the following terms.
</p>
<div class="highlight">
<p><strong>Renewal Term:</strong> {renewal_start} through {renewal_end}</p>
<p><strong>Monthly Rent:</strong> {new_monthly_rent}</p>
<p><strong>Change from Current:</strong> {rent_change_summary}</p>
</div>
<p>
To accept this offer, please sign and return this notice no later than
<strong>{response_deadline}</strong>. If we do not hear from you by that date,
we will assume you do not intend to renew and will begin re-listing the unit.
</p>
<p>
If you have any questions or would like to discuss these terms, please contact
our leasing office at {contact_email} or {contact_phone}.
</p>
<div class="signature-block">
<div class="sig-line"></div>
<div class="sig-label">Property Manager Signature</div>
<div class="sig-name">{manager_name}</div>
<div class="sig-label">{company_name}</div>
</div>
</body>
</html>"""
def build_notice_html(lease: dict) -> str:
today = date.today()
renewal_start = date.fromisoformat(lease["end_date"]) + timedelta(days=1)
renewal_end = renewal_start.replace(year=renewal_start.year + 1) - timedelta(days=1)
deadline = today + timedelta(days=14)
current_rent = lease["monthly_rent_cents"]
new_rent = int(current_rent * 1.03) # 3% increase example
rent_diff = new_rent - current_rent
rent_change = f"+${rent_diff / 100:.0f}/mo (+3%)"
return RENEWAL_TEMPLATE.format(
company_name = lease["company_name"],
notice_date = today.strftime("%B %d, %Y"),
unit_id = lease["unit_id"],
tenant_name = lease["tenant_name"],
tenant_salutation = lease["tenant_first_name"],
property_address = lease["property_address"],
current_lease_end = date.fromisoformat(lease["end_date"]).strftime("%B %d, %Y"),
renewal_start = renewal_start.strftime("%B %d, %Y"),
renewal_end = renewal_end.strftime("%B %d, %Y"),
new_monthly_rent = f"${new_rent / 100:,.0f}/month",
rent_change_summary = rent_change,
response_deadline = deadline.strftime("%B %d, %Y"),
contact_email = lease["contact_email"],
contact_phone = lease["contact_phone"],
manager_name = lease["manager_name"],
)
async def batch_renewal_notices(leases: list[dict]) -> list[dict]:
"""
Generate renewal notices for all expiring leases in one batch call.
The batch endpoint accepts up to 50 documents per request.
Results are returned in the same order as the input.
"""
documents = [
{
"html": build_notice_html(lease),
"filename": f"renewal-notice-{lease['unit_id']}-{date.today().isoformat()}.pdf",
"store": True,
"options": {"format": "Letter"},
}
for lease in leases
]
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
BATCH_URL,
headers={
"Authorization": f"Bearer {PDFPIPE_KEY}",
"Content-Type": "application/json",
},
json={"documents": documents},
)
response.raise_for_status()
data = response.json()
results = []
for i, lease in enumerate(leases):
results.append({
"unit_id": lease["unit_id"],
"tenant_name": lease["tenant_name"],
"document_url": data["results"][i]["document_url"],
"document_id": data["results"][i]["document_id"],
})
return results
if __name__ == "__main__":
# Pull leases expiring within 30 days from your property management DB
expiring_leases = fetch_leases_expiring_within(days=30)
# Process in chunks of 50 (batch endpoint limit)
chunk_size = 50
all_results = []
for i in range(0, len(expiring_leases), chunk_size):
chunk = expiring_leases[i : i + chunk_size]
results = asyncio.run(batch_renewal_notices(chunk))
all_results.extend(results)
for r in all_results:
print(f"Unit {r['unit_id']} ({r['tenant_name']}): {r['document_url']}")
# Save document_url and document_id back to the lease record
# so the tenant portal can display the notice immediatelyStoring documents for the tenant portal (TypeScript)
Pass store: true on every lease or notice you generate, then persist the returned document_url and document_id in your database. The tenant portal can then list and link to all their documents without hitting the PDF API on every page load. Storage duration depends on your plan: 30 days on Starter, 365 days on Growth, and 2 years on Scale and Business.
// routes/portal/documents.ts
// Returns all stored PDFs for a tenant so the portal can list and link to them.
// Documents are generated with store: true so they have permanent URLs.
import { db } from "@/lib/db";
type StoredDocument = {
documentId: string;
filename: string;
documentUrl: string;
createdAt: string;
type: "lease" | "renewal_notice" | "move_in_checklist" | "other";
};
export async function getTenantDocuments(tenantId: string): Promise<StoredDocument[]> {
// Your database table that maps tenants to generated document URLs
const rows = await db
.selectFrom("tenant_documents")
.selectAll()
.where("tenant_id", "=", tenantId)
.orderBy("created_at", "desc")
.execute();
return rows.map((row) => ({
documentId: row.document_id,
filename: row.filename,
documentUrl: row.document_url, // permanent URL from store: true
createdAt: row.created_at,
type: row.document_type,
}));
}
// When generating a PDF with store: true, save the returned URL in your DB:
export async function generateAndStoreLease(data: LeaseData): Promise<StoredDocument> {
const res = 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: renderLeaseTemplate(data),
filename: `lease-${data.leaseId}.pdf`,
store: true, // permanent storage, returns document_url
options: { format: "Letter" },
}),
});
if (!res.ok) throw new Error(`PDF generation failed: ${res.status}`);
const { document_url, document_id } = await res.json();
// Persist the URL so the portal can show it without calling the API again
await db
.insertInto("tenant_documents")
.values({
tenant_id: data.tenantId,
document_id,
document_url,
filename: `lease-${data.leaseId}.pdf`,
document_type: "lease",
created_at: new Date().toISOString(),
})
.execute();
return { documentId: document_id, filename: `lease-${data.leaseId}.pdf`,
documentUrl: document_url, createdAt: new Date().toISOString(), type: "lease" };
}CSS for rental documents
Set 1-inch margins with @page { margin: 1in }, use counter(page) and counter(pages) in @bottom-center for automatic page numbering, and add a page-break-inside: avoid on the signature block so landlord and tenant lines always print together on the same page. Georgia or Times New Roman gives the document a legible, authoritative feel appropriate for legal agreements.
<!-- Rental/legal document CSS: serif body, signature lines, footer page numbers -->
<style>
/* Page setup: 1-inch margins, US Letter */
@page {
margin: 1in;
size: letter; /* swap for: size: A4 */
/* "Page N of M" footer on every page */
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-family: Georgia, serif;
font-size: 10px;
color: #555;
}
/* Running header: property address after the first page */
@top-right {
content: string(propertyAddress); /* set via CSS string() on the .address element */
font-family: Georgia, serif;
font-size: 9px;
color: #888;
}
}
/* No running header on the title page */
@page :first {
@top-right { content: none; }
@bottom-center { content: none; }
}
/* Body: classic legal serif, generous line spacing for annotations */
body {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 12pt;
line-height: 1.6;
color: #1a1a1a;
margin: 0; /* @page margin handles all whitespace */
}
/* Section headings: uppercase small-caps style */
h2 {
font-size: 11pt;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.07em;
margin-top: 28px;
margin-bottom: 10px;
page-break-after: avoid; /* never orphan a heading at the bottom of a page */
}
p { margin-bottom: 14px; }
/* Signature block with tall blank lines for wet signatures */
.signature-block {
margin-top: 56px;
display: flex;
gap: 80px;
page-break-inside: avoid; /* never split signatures across pages */
}
.sig-col { flex: 1; }
/* The blank space above the signature line */
.sig-space { height: 40px; }
/* The actual printed line */
.sig-line { border-bottom: 1px solid #1a1a1a; margin-bottom: 6px; }
.sig-label { font-size: 9pt; color: #555; }
.sig-name { font-size: 10pt; font-weight: bold; margin-top: 4px; }
/* Date line below the name */
.sig-date { font-size: 9pt; color: #555; margin-top: 8px; }
</style>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.