Use case
Legal Documents PDF API
Law firms, CLM platforms, and legal SaaS apps need audit-ready PDFs generated at scale: contracts sent on signing, pleadings compiled from data, letters generated on demand. Every document needs consistent formatting, precise typography, and must be archivable with a stable URL.
Engagement letter (Node.js / TypeScript)
An engagement letter template using Georgia serif typography, 1-inch margins via CSS @page, client name, matter number, an attorney signature block, and page numbers in the footer via CSS counters. Call with store: true to get a persistent download URL you can attach to the matter record in your practice management system.
// handlers/generate-engagement-letter.ts
import Mustache from "mustache";
import { readFileSync } from "fs";
const template = readFileSync("./templates/engagement-letter.html", "utf-8");
// templates/engagement-letter.html (excerpt)
const TEMPLATE_HTML = `<!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.5;
color: #1a1a1a;
margin: 0;
}
.letterhead {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
padding-bottom: 16px;
border-bottom: 2px solid #1a1a1a;
}
.firm-name { font-size: 18pt; font-weight: bold; letter-spacing: -0.02em; }
.firm-meta { font-size: 9pt; color: #555; margin-top: 4px; }
.date-block { font-size: 10pt; color: #555; text-align: right; }
.matter-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: 24px; }
p { margin-bottom: 14px; }
.section-heading {
font-size: 10pt;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: bold;
margin-top: 28px;
margin-bottom: 10px;
}
.fee-table { width: 100%; border-collapse: collapse; margin: 20px 0; }
.fee-table th {
text-align: left;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid #1a1a1a;
padding: 6px 0;
}
.fee-table td { font-size: 11pt; padding: 10px 0; border-bottom: 1px solid #e0e0e0; }
.signature-block { margin-top: 56px; display: flex; gap: 80px; }
.sig-col { flex: 1; }
.sig-line { border-bottom: 1px solid #1a1a1a; height: 32px; 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>
<div class="firm-name">{{firmName}}</div>
<div class="firm-meta">Attorneys at Law • {{firmAddress}}</div>
</div>
<div class="date-block">{{letterDate}}</div>
</div>
<div class="matter-ref">
Matter No. {{matterNumber}} • Re: {{matterDescription}}
</div>
<p>
{{clientName}}<br />
{{clientAddress}}
</p>
<h1>Engagement Letter</h1>
<p>Dear {{clientSalutation}},</p>
<p>
We are pleased to confirm our engagement to represent you in connection with
{{matterDescription}}. This letter sets out the terms of our engagement.
</p>
<p class="section-heading">Scope of Representation</p>
<p>{{scopeOfWork}}</p>
<p class="section-heading">Fee Arrangement</p>
<table class="fee-table">
<thead>
<tr><th>Attorney</th><th>Role</th><th>Hourly Rate</th></tr>
</thead>
<tbody>
{{#attorneys}}
<tr>
<td>{{name}}</td>
<td>{{role}}</td>
<td>{{rate}}</td>
</tr>
{{/attorneys}}
</tbody>
</table>
<p class="section-heading">Retainer</p>
<p>
We require an advance retainer of <strong>{{retainerAmount}}</strong> to be held in our
client trust account and applied against fees as they are earned.
</p>
<p class="section-heading">Confidentiality</p>
<p>
All communications between you and this firm are protected by the attorney-client
privilege and our professional duty of confidentiality.
</p>
<p>
Please sign and return a copy of this letter to confirm your agreement to these terms.
We look forward to working with you.
</p>
<div class="signature-block">
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Attorney Signature</div>
<div class="sig-name">{{attorneyName}}, {{attorneyTitle}}</div>
<div class="sig-label">{{firmName}}</div>
</div>
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Client Signature</div>
<div class="sig-name">{{clientName}}</div>
<div class="sig-label">Date: _______________</div>
</div>
</div>
</body>
</html>`;
type EngagementLetterData = {
clientName: string;
clientAddress: string;
clientSalutation: string;
matterNumber: string;
matterDescription: string;
scopeOfWork: string;
retainerAmount: string;
attorneys: { name: string; role: string; rate: string }[];
matterId: string;
};
export async function generateEngagementLetter(data: EngagementLetterData): Promise<string> {
const html = Mustache.render(TEMPLATE_HTML, {
firmName: "Harrington & Associates LLP",
firmAddress: "One Market Street, Suite 3200, San Francisco, CA 94105",
letterDate: new Date().toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
}),
attorneyName: "Catherine Harrington",
attorneyTitle: "Managing Partner",
...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: `engagement-letter-${data.matterId}.pdf`,
store: true,
options: { format: "Letter" },
}),
});
const { document_url } = await res.json();
return document_url;
}NDA generation from a CLM webhook (Python)
A FastAPI endpoint that receives a CLM contract.created event, builds the HTML for a mutual NDA, calls the PDF API, and returns the document URL to the CLM system for attachment. The CLM can then prompt both parties to sign directly from the contract record.
# webhooks/clm_handler.py
# Receives contract.created events from a CLM platform (e.g. Ironclad, Juro, ContractSafe)
# and generates a stored NDA PDF, returning the document URL to the CLM system.
import os
import httpx
from fastapi import FastAPI, Request, HTTPException
from datetime import date
app = FastAPI()
PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf"
NDA_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
@page {{ margin: 1in; size: letter; }}
body {{ font-family: Georgia, serif; font-size: 12pt; line-height: 1.5; color: #1a1a1a; margin: 0; }}
h1 {{ font-size: 15pt; font-weight: bold; text-align: center; margin-bottom: 8px; }}
.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: 4px 0; font-size: 11pt; }}
h2 {{ font-size: 11pt; font-weight: bold; text-transform: uppercase;
letter-spacing: 0.07em; margin-top: 28px; margin-bottom: 10px; }}
p {{ margin-bottom: 14px; }}
.signature-block {{ margin-top: 56px; display: flex; gap: 80px; }}
.sig-col {{ flex: 1; }}
.sig-line {{ border-bottom: 1px solid #1a1a1a; height: 32px; margin-bottom: 6px; }}
.sig-label {{ font-size: 9pt; color: #555; }}
.sig-name {{ font-size: 10pt; font-weight: bold; margin-top: 4px; }}
</style>
</head>
<body>
<h1>Mutual Non-Disclosure Agreement</h1>
<div class="subtitle">
Contract No. {contract_id} • Effective {effective_date}
</div>
<div class="parties">
<p><strong>Disclosing Party:</strong> {party_a_name}, {party_a_jurisdiction}</p>
<p><strong>Receiving Party:</strong> {party_b_name}, {party_b_jurisdiction}</p>
<p><strong>Purpose:</strong> {purpose}</p>
</div>
<h2>1. Definition of Confidential Information</h2>
<p>
"Confidential Information" means any non-public technical, business, financial,
or other information disclosed by either party that is designated as confidential
or that reasonably should be understood to be confidential given the nature of the
information and the circumstances of disclosure.
</p>
<h2>2. Obligations of Receiving Party</h2>
<p>
Each party, as Receiving Party, shall: (a) hold the other party's Confidential
Information in strict confidence using at least the same degree of care it uses
to protect its own confidential information, but no less than reasonable care;
(b) not disclose such information to any third party without prior written consent;
and (c) use such information solely for the Purpose stated above.
</p>
<h2>3. Term</h2>
<p>
This Agreement shall remain in effect for a period of {term_years} years from
the Effective Date, unless earlier terminated by mutual written agreement.
Obligations with respect to Confidential Information shall survive termination.
</p>
<h2>4. Return or Destruction</h2>
<p>
Upon written request, each party shall promptly return or destroy all Confidential
Information received from the other party and certify such destruction in writing.
</p>
<h2>5. Governing Law</h2>
<p>
This Agreement shall be governed by and construed in accordance with the laws of
{governing_law}, without regard to its conflict of law provisions.
</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">{party_a_name}</div>
<div class="sig-label">Date: _______________</div>
</div>
<div class="sig-col">
<div class="sig-line"></div>
<div class="sig-label">Authorised Signatory</div>
<div class="sig-name">{party_b_name}</div>
<div class="sig-label">Date: _______________</div>
</div>
</div>
</body>
</html>"""
@app.post("/webhooks/clm")
async def clm_webhook(request: Request):
payload = await request.json()
event_type = payload.get("event")
if event_type != "contract.created":
return {"status": "ignored"}
contract = payload["data"]
contract_id = contract["id"]
template_type = contract.get("template_type", "")
if template_type != "nda":
return {"status": "skipped", "reason": "not an NDA template"}
html = NDA_TEMPLATE.format(
contract_id=contract_id,
effective_date=date.today().strftime("%B %d, %Y"),
party_a_name=contract["party_a"]["name"],
party_a_jurisdiction=contract["party_a"].get("jurisdiction", ""),
party_b_name=contract["party_b"]["name"],
party_b_jurisdiction=contract["party_b"].get("jurisdiction", ""),
purpose=contract.get("purpose", "Evaluation of a potential business relationship"),
term_years=contract.get("term_years", 2),
governing_law=contract.get("governing_law", "the State of Delaware"),
)
response = httpx.post(
PDFPIPE_URL,
headers={
"Authorization": f"Bearer {PDFPIPE_KEY}",
"Content-Type": "application/json",
},
json={
"html": html,
"filename": f"nda-{contract_id}.pdf",
"store": True,
"options": {"format": "Letter"},
},
timeout=30,
)
response.raise_for_status()
data = response.json()
# Return the document URL so the CLM system can attach it to the contract record
return {
"status": "ok",
"contract_id": contract_id,
"document_url": data["document_url"],
"document_id": data["document_id"],
}Batch demand letters (Python, asyncio)
When a docket contains dozens of open demand letter matters, use the batch endpoint to generate up to 50 documents in a single HTTP call rather than looping over individual requests. The batch endpoint accepts the full array of documents, returns results in input order, and charges the same per-document rate as the single-document endpoint.
# scripts/batch_demand_letters.py
# Generate 50 demand letters concurrently using asyncio and the batch endpoint.
# Each letter has a different respondent name, case number, and claim amount.
import os
import asyncio
import httpx
from datetime import date
PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
BATCH_URL = "https://api.pdfpipe.xyz/v1/pdf/batch"
DEMAND_LETTER_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
@page {{ margin: 1in; size: letter; }}
body {{ font-family: Georgia, serif; font-size: 12pt; line-height: 1.5; color: #1a1a1a; margin: 0; }}
.letterhead {{
border-bottom: 2px solid #1a1a1a;
margin-bottom: 36px;
padding-bottom: 16px;
display: flex;
justify-content: space-between;
}}
.firm-name {{ font-size: 16pt; font-weight: bold; }}
.case-ref {{
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #555;
margin-bottom: 32px;
}}
p {{ margin-bottom: 14px; }}
.demand-amount {{ font-size: 14pt; font-weight: bold; }}
.response-deadline {{ color: #8b0000; font-weight: bold; }}
.signature-block {{ margin-top: 56px; }}
.sig-line {{ border-bottom: 1px solid #1a1a1a; height: 32px; 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="firm-name">Harrington & Associates LLP</div>
<div style="font-size: 10pt; color: #555; text-align: right;">{letter_date}</div>
</div>
<div class="case-ref">Case No. {case_number} • VIA CERTIFIED MAIL</div>
<p>
{respondent_name}<br />
{respondent_address}
</p>
<p>
Re: Demand for Payment — {matter_description}
</p>
<p>Dear {respondent_salutation},</p>
<p>
This firm represents {client_name} in connection with the above-referenced matter.
We write to demand immediate payment of the sum owed to our client.
</p>
<p>
As of the date of this letter, the total amount due and owing is
<span class="demand-amount">{demand_amount}</span>, representing {amount_breakdown}.
</p>
<p>
Demand is hereby made that you remit full payment of <strong>{demand_amount}</strong>
within <span class="response-deadline">30 days</span> of the date of this letter.
Failure to respond or remit payment will result in the commencement of legal proceedings
without further notice, in which case our client will also seek attorneys' fees,
court costs, and pre-judgment interest as permitted by applicable law.
</p>
<p>
If you dispute this claim or wish to discuss settlement, contact this office
within the response period.
</p>
<div class="signature-block">
<div class="sig-line"></div>
<div class="sig-label">Attorney Signature</div>
<div class="sig-name">Catherine Harrington, Esq.</div>
<div class="sig-label">Harrington & Associates LLP</div>
</div>
</body>
</html>"""
def build_letter_html(case: dict) -> str:
return DEMAND_LETTER_TEMPLATE.format(
letter_date=date.today().strftime("%B %d, %Y"),
case_number=case["case_number"],
respondent_name=case["respondent_name"],
respondent_address=case["respondent_address"],
respondent_salutation=case["respondent_salutation"],
client_name=case["client_name"],
matter_description=case["matter_description"],
demand_amount=case["demand_amount"],
amount_breakdown=case["amount_breakdown"],
)
async def batch_generate_demand_letters(cases: list[dict]) -> list[dict]:
"""
Generate up to 50 demand letters in a single HTTP call using the batch endpoint.
Returns a list of results aligned with the input order.
"""
documents = [
{
"html": build_letter_html(c),
"filename": f"demand-letter-{c['case_number']}.pdf",
"store": True,
"options": {"format": "Letter"},
}
for c in cases
]
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 is an array aligned with the input order
results = []
for i, case in enumerate(cases):
results.append({
"case_number": case["case_number"],
"respondent_name": case["respondent_name"],
"document_url": data["results"][i]["document_url"],
"document_id": data["results"][i]["document_id"],
})
return results
# Example: load cases from your matter management system and generate all letters at once
if __name__ == "__main__":
cases = load_open_cases() # returns up to 50 case dicts
results = asyncio.run(batch_generate_demand_letters(cases))
for r in results:
print(f"Case {r['case_number']} ({r['respondent_name']}): {r['document_url']}")CSS for legal typography
Full browser CSS makes precise legal formatting straightforward. Set 1-inch margins with @page { margin: 1in }, use CSS counters for automatic page numbering in headers and footers, apply Georgia or a comparable serif for body text, and mark signature blocks with page-break-inside: avoid so they never split across pages. No special configuration is needed beyond standard CSS.
<!-- Legal document CSS: 1-inch margins, serif body, page numbers via CSS counters -->
<style>
/* Page setup: 1-inch margins on all sides, US Letter or A4 */
@page {
margin: 1in;
size: letter; /* or: size: A4 */
/* Page numbers in the footer using CSS counters */
@bottom-center {
content: counter(page) " of " counter(pages);
font-family: Georgia, serif;
font-size: 10px;
color: #555;
}
/* Optional: firm name in the header on all pages after the first */
@top-right {
content: "Harrington & Associates LLP";
font-family: Georgia, serif;
font-size: 9px;
color: #888;
}
}
/* Suppress header/footer on the cover page */
@page :first {
@top-right { content: none; }
@bottom-center { content: none; }
}
/* Body: serif typeface, legal-standard line height */
body {
font-family: 'Georgia', serif; /* Georgia is universally available */
font-size: 12pt;
line-height: 1.5;
color: #1a1a1a;
margin: 0; /* margin handled by @page above */
}
/* Headings: small caps style for section titles */
h2 {
font-size: 11pt;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.07em;
margin-top: 28px;
margin-bottom: 10px;
}
/* Paragraph spacing */
p { margin-bottom: 14px; }
/* Keep headings with their following paragraph (avoids orphaned headings) */
h2 { page-break-after: avoid; }
/* Avoid breaking inside a signature block across pages */
.signature-block { page-break-inside: avoid; }
</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.