Use case
Financial Reports PDF API
Finance teams need pixel-perfect reports with charts, tables, and branded cover pages delivered on schedule. Whether you are running end-of-quarter batch generation, on-demand account statements, or advisor report workflows, a single API call turns an HTML template into a stored, downloadable PDF.
Monthly account statement (Node.js)
Build the statement HTML server-side from your account data: an opening and closing balance summary grid, a static chart image fetched from your chart service, and a full transactions table with alternating row colors. Pass store: true so the PDF is retained and the returned document_url can be emailed to the account holder or saved in your database.
// statements/generate-account-statement.ts
import { readFileSync } from "fs";
const PDFPIPE_KEY = process.env.PDFPIPE_KEY!;
const PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf";
type Transaction = {
date: string;
description: string;
debit: string;
credit: string;
balance: string;
};
type AccountData = {
accountId: string;
accountNumber: string;
accountHolder: string;
statementPeriod: string;
openingBalance: string;
closingBalance: string;
totalDebits: string;
totalCredits: string;
transactions: Transaction[];
};
function buildStatementHtml(account: AccountData): string {
const rows = account.transactions
.map(
(tx, i) => `
<tr class="${i % 2 === 0 ? "row-even" : "row-odd"}">
<td>${tx.date}</td>
<td>${tx.description}</td>
<td class="num">${tx.debit || ""}</td>
<td class="num">${tx.credit || ""}</td>
<td class="num bold">${tx.balance}</td>
</tr>`
)
.join("");
return `<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', sans-serif; color: #1a1a1a; margin: 0; padding: 48px 56px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
.brand { font-size: 22px; font-weight: 700; color: #d23a1d; }
.period { font-size: 13px; color: #666; text-align: right; }
h1 { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 32px; }
.metric { background: #f9f9f9; padding: 14px 18px; }
.metric-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; color: #999; margin-bottom: 4px; }
.metric-value { font-size: 17px; font-weight: 600; font-family: 'IBM Plex Mono', monospace; color: #1a1a1a; }
/* Chart placeholder from a separate chart rendering service */
.chart-placeholder { width: 100%; height: 160px; object-fit: cover; margin-bottom: 32px; border: 1px solid #e5e5e5; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead tr { border-bottom: 2px solid #1a1a1a; }
th { text-align: left; padding: 8px 10px 10px; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.07em; color: #999; }
th.num, td.num { text-align: right; font-family: 'IBM Plex Mono', monospace; }
td { padding: 10px; border-bottom: 1px solid #f0f0f0; }
.row-even { background: #fff; }
.row-odd { background: #f9f9f9; }
.bold { font-weight: 600; }
@page { margin: 0; size: A4; }
</style>
</head>
<body>
<div class="header">
<div class="brand">Acme Finance</div>
<div class="period">
Account: ${account.accountNumber}<br/>
${account.statementPeriod}
</div>
</div>
<h1>Account Statement: ${account.accountHolder}</h1>
<div class="summary">
<div class="metric">
<div class="metric-label">Opening Balance</div>
<div class="metric-value">${account.openingBalance}</div>
</div>
<div class="metric">
<div class="metric-label">Closing Balance</div>
<div class="metric-value">${account.closingBalance}</div>
</div>
<div class="metric">
<div class="metric-label">Total Debits</div>
<div class="metric-value">${account.totalDebits}</div>
</div>
<div class="metric">
<div class="metric-label">Total Credits</div>
<div class="metric-value">${account.totalCredits}</div>
</div>
</div>
<!-- Balance trend chart from your chart service, rendered as a static image -->
<img
class="chart-placeholder"
src="https://charts.example.com/balance-trend?account=${account.accountId}&period=monthly"
alt="Balance trend"
/>
<table>
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
<th class="num">Balance</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</body>
</html>`;
}
export async function generateAccountStatement(account: AccountData): Promise<string> {
const html = buildStatementHtml(account);
const res = await fetch(PDFPIPE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
filename: `statement-${account.accountId}-${account.statementPeriod.replace(/\s/g, "-")}.pdf`,
store: true,
options: { format: "A4" },
}),
});
const { document_url } = await res.json();
return document_url;
}End-of-quarter batch reports (Python, asyncio)
At the close of each quarter, query all active accounts, build the HTML for each one, and send every document in a single batch request. The batch endpoint returns results in the same order as the input, so you can zip accounts and URLs together and write each stored link back to the account record in one pass.
The batch endpoint accepts up to 50 documents per call at the same per-document rate as the single-document endpoint. For larger account books, slice into chunks of 50 and run them concurrently with asyncio.gather.
# reports/batch_quarterly.py
# End-of-quarter: generate a PDF report for every active account in one batch call.
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"
def build_report_html(account: dict, quarter: str) -> str:
"""
Render a quarterly P&L / portfolio summary for one account.
Replace the template body with your real Jinja2 or string-format template.
"""
rows = "".join(
f'<tr class="{"even" if i % 2 == 0 else "odd"}">'
f'<td>{line["category"]}</td>'
f'<td class="num">{line["q_prev"]}</td>'
f'<td class="num">{line["q_curr"]}</td>'
f'<td class="num {"pos" if line["change"] >= 0 else "neg"}">'
f'{line["change_pct"]}</td></tr>'
for i, line in enumerate(account["line_items"])
)
return f"""<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: 'Inter', sans-serif; color: #1a1a1a; padding: 48px 56px; }}
h1 {{ font-size: 20px; font-weight: 700; }}
table {{ width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 24px; }}
th {{ text-align: left; padding: 8px 10px; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.06em; color: #999; border-bottom: 2px solid #1a1a1a; }}
td {{ padding: 10px; border-bottom: 1px solid #f0f0f0; }}
.num {{ text-align: right; font-family: 'IBM Plex Mono', monospace; }}
.even {{ background: #fff; }} .odd {{ background: #f9f9f9; }}
.pos {{ color: #166534; }} .neg {{ color: #991b1b; }}
@page {{ margin: 0; size: A4; }}
</style>
</head>
<body>
<h1>Quarterly Report: {account['holder']} ({quarter})</h1>
<p style="color:#666;font-size:13px;">Account {account['account_number']} | As of {date.today().strftime('%B %d, %Y')}</p>
<table>
<thead><tr><th>Category</th><th class="num">Prior Quarter</th><th class="num">This Quarter</th><th class="num">Change</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</body>
</html>"""
async def batch_quarterly_reports(accounts: list[dict], quarter: str) -> list[str]:
"""
Build one PDF request per account, send a single batch call,
and return the list of stored document_url values in input order.
"""
documents = [
{
"html": build_report_html(account, quarter),
"filename": f"quarterly-{account['account_id']}-{quarter}.pdf",
"store": True,
"options": {"format": "A4"},
}
for account in accounts
]
async with httpx.AsyncClient(timeout=120) 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()
results = response.json()["results"]
urls = [r["document_url"] for r in results]
# Persist each URL back to the account record
for account, url in zip(accounts, urls):
await db.accounts.update(account["account_id"], {"latest_quarterly_report_url": url})
return urls
# Called at the end of each quarter
if __name__ == "__main__":
current_quarter = "Q1-2026"
active_accounts = db.accounts.find_all(status="active")
urls = asyncio.run(batch_quarterly_reports(active_accounts, current_quarter))
print(f"Generated {len(urls)} quarterly reports.")Scheduled monthly delivery (TypeScript)
Create one schedule with POST /v1/schedules and PDFPipe runs it on the 1st of each month in your time zone: every account's statement is rendered, stored, and sent to your webhook as a signed event with the download links. No cron job or worker to host. A failed account is reported in the run result and never stops the rest.
Available on Starter and above, up to your plan's batch size per run. See scheduled batches in the docs.
// jobs/create-statement-schedule.ts
// Run once. PDFPipe then renders the previous month's statement for every
// account at 06:00 on the 1st, stores each PDF and calls your webhook.
const accounts = await db.accounts.findAll({ status: "active" });
const res = await fetch("https://api.pdfpipe.xyz/v1/schedules", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Monthly account statements",
every: "monthly",
day_of_month: 1,
at: "06:00",
timezone: "Europe/London",
// Your own statement page, rendered live at run time, so each PDF has
// the ledger as it stands. {{run.previous_month}} is e.g. "2026-09".
url: "https://app.example.com/statements/{{account_id}}?period={{run.previous_month}}",
filename: "statement-{{account_id}}-{{run.previous_month}}.pdf",
data: accounts.map((a) => ({ account_id: a.id })),
options: { format: "A4", print_background: true },
webhook_url: "https://app.example.com/hooks/statements",
webhook_secret: process.env.PDFPIPE_WEBHOOK_SECRET,
}),
});
const schedule = await res.json(); // { id: "sch_...", next_run_at: "...", ... }
// When accounts open or close, replace the list:
// PATCH /v1/schedules/:id { data: [...] }
// Your webhook receives a signed schedule.run.complete event after each run,
// with one stored document link per account. Email them from there.Table formatting tips (pure CSS)
The CSS below covers everything financial tables need: striped rows, right-aligned monospace numbers, positive/negative amount colors, and a two-column metric grid for key figures. No JavaScript charting library is required in the PDF itself. Embed charts as static images from your chart rendering service, as shown in the statement snippet above.
/* finance-tables.css
Drop-in styles for transaction tables and key-metric grids in PDF reports.
No JavaScript charting library required. */
/* Striped transaction rows */
table.ledger tr:nth-child(even) { background: #f9f9f9; }
table.ledger tr:nth-child(odd) { background: #ffffff; }
/* Right-aligned numbers with monospace font */
table.ledger td.num,
table.ledger th.num {
text-align: right;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
letter-spacing: 0.01em;
}
/* Positive / negative amounts */
.amount-positive { color: #166534; }
.amount-negative { color: #991b1b; }
/* Two-column key-metric grid (opening balance, closing balance, etc.) */
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 28px;
}
.metric-cell {
padding: 14px 18px;
background: #f9f9f9;
border-left: 3px solid #d23a1d;
}
.metric-cell .label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #999;
margin-bottom: 4px;
}
.metric-cell .value {
font-size: 18px;
font-weight: 600;
font-family: 'IBM Plex Mono', monospace;
color: #1a1a1a;
}Plans
| Plan | Documents/mo | Storage | Price |
|---|---|---|---|
| Hobby | 100 | 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 | $299/mo |
Need more than 100,000 documents per month? Contact us for enterprise pricing.