Use case
Account Statement PDF API
Fintechs, banks, and subscription businesses send statements every month: bank account statements, SaaS billing summaries, credit card statements, and subscription renewal history. Each one is the same job, an HTML template filled with one account's data turned into a stored, downloadable PDF. One API call does it, and a scheduled batch run does it for every account at once.
What you get
Monthly scheduling
Run a cron job on the 1st of each month to generate the previous period for every account. Trigger it from node-cron, a Django management command, or any scheduler.
Batch every account
Iterate active accounts in one run, or send up to 50 documents per call to the batch endpoint. A single failure never aborts the rest of the book.
Accurate page breaks
Long transaction tables flow across pages with the header repeated on each one and no row ever split, using standard print CSS. No manual pagination math.
Password protection
Encrypt sensitive statements with options.password so only the account holder can open the file. Deliver the link and the password through separate channels.
Store and share a link
Pass store: true and get a stable document_url back. Email it, save it to the customer portal, or write it to the account record for later download.
One template, every statement
Render bank statements, SaaS billing summaries, credit card statements, and subscription renewal history from the same HTML template. Swap the data, keep the layout.
Statement HTML template
A single template covers every statement type. Bind it to one account with {{accountHolder.name}}, {{accountNumber}}, the opening and closing balances, and a transaction loop. The print CSS keeps the header on every page and stops rows from splitting, so a statement with 5 transactions and one with 500 both paginate correctly with no extra work.
<!-- templates/statement.html -->
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', sans-serif; color: #1a1a1a; margin: 0; padding: 44px 52px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 36px; }
.brand { font-size: 22px; font-weight: 700; color: #d23a1d; }
.meta { text-align: right; font-size: 13px; color: #666; line-height: 1.6; }
.meta .acct { font-family: 'IBM Plex Mono', monospace; color: #1a1a1a; }
h1 { font-size: 18px; font-weight: 600; margin: 0 0 24px; }
.summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 32px; }
.cell { padding: 14px 18px; background: #f9f9f9; border-left: 3px solid #d23a1d; }
.cell .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; color: #999; margin-bottom: 4px; }
.cell .value { font-size: 18px; font-weight: 600; font-family: 'IBM Plex Mono', monospace; }
/* Long transaction tables: repeat the header on every printed page
and never split a row across a page boundary. */
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
tr { page-break-inside: avoid; }
th { text-align: left; padding: 8px 10px 10px; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.06em; color: #999; border-bottom: 2px solid #1a1a1a; }
th.num, td.num { text-align: right; font-family: 'IBM Plex Mono', monospace; }
td { padding: 9px 10px; border-bottom: 1px solid #f0f0f0; }
tr:nth-child(even) td { background: #fbfbfb; }
.bold { font-weight: 600; }
@page { margin: 18mm 0 16mm; size: A4; }
</style>
</head>
<body>
<div class="header">
<div class="brand">{{companyName}}</div>
<div class="meta">
Account holder: {{accountHolder.name}}<br/>
Account: <span class="acct">{{accountNumber}}</span><br/>
Period: {{statementPeriod}}
</div>
</div>
<h1>Account Statement</h1>
<div class="summary">
<div class="cell">
<div class="label">Opening Balance</div>
<div class="value">{{openingBalance}}</div>
</div>
<div class="cell">
<div class="label">Closing Balance</div>
<div class="value">{{closingBalance}}</div>
</div>
</div>
<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>
{{#each transactions}}
<tr>
<td>{{this.date}}</td>
<td>{{this.description}}</td>
<td class="num">{{this.debit}}</td>
<td class="num">{{this.credit}}</td>
<td class="num bold">{{this.balance}}</td>
</tr>
{{/each}}
</tbody>
</table>
</body>
</html>Monthly batch job (Node.js)
Schedule a cron job that runs on the 1st of each month, queries every active account, renders the template, and posts each statement to the API. Pass store: true so the returned document_url can be emailed and saved. Promise.allSettled isolates failures so one bad account never blocks the rest of the run.
// jobs/monthly-statements.ts
// Cron job: on the 1st of each month, generate a statement PDF for every
// active account, store it, and email the download link.
// Works with node-cron (self-hosted) or any scheduler that can invoke a function.
import cron from "node-cron";
import Handlebars from "handlebars";
import { readFileSync } from "fs";
const PDFPIPE_KEY = process.env.PDFPIPE_KEY!;
const PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf";
const template = Handlebars.compile(
readFileSync("./templates/statement.html", "utf-8")
);
type Transaction = {
date: string;
description: string;
debit: string;
credit: string;
balance: string;
};
type Account = {
id: string;
accountNumber: string;
email: string;
accountHolder: { name: string };
};
function buildStatementHtml(account: Account, period: string, ledger: {
openingBalance: string;
closingBalance: string;
transactions: Transaction[];
}): string {
return template({
companyName: "Acme Bank",
accountHolder: account.accountHolder,
accountNumber: account.accountNumber,
statementPeriod: period,
openingBalance: ledger.openingBalance,
closingBalance: ledger.closingBalance,
transactions: ledger.transactions,
});
}
async function generateStatement(account: Account, period: string): Promise<string> {
const ledger = await db.ledger.forPeriod(account.id, period);
const html = buildStatementHtml(account, period, ledger);
const res = await fetch(PDFPIPE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
filename: `statement-${account.accountNumber}-${period.replace(/\s/g, "-")}.pdf`,
store: true,
options: { format: "A4" },
}),
});
if (!res.ok) throw new Error(`PDF generation failed: ${res.status}`);
const { document_url } = await res.json();
return document_url;
}
// node-cron syntax: minute hour day-of-month month day-of-week
// "0 6 1 * *" runs at 06:00 on the 1st of every month.
cron.schedule("0 6 1 * *", async () => {
const period = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
}).format(new Date(Date.now() - 24 * 60 * 60 * 1000)); // previous month
const accounts: Account[] = await db.accounts.findAll({ status: "active" });
// allSettled so one bad account does not abort the whole run.
const results = await Promise.allSettled(
accounts.map((account) => generateStatement(account, period))
);
for (let i = 0; i < accounts.length; i++) {
const result = results[i];
if (result.status === "fulfilled") {
await sendEmail({
to: accounts[i].email,
subject: `Your ${period} statement is ready`,
body: `Download your statement: ${result.value}`,
});
await db.statements.record(accounts[i].id, period, result.value);
} else {
logger.error("Statement generation failed", {
accountId: accounts[i].id,
error: result.reason,
});
}
}
});Django management command
If your billing system is Django, a management command is the natural home for the monthly run. Call it as python manage.py generate_statements --period "May 2026" from cron or Celery beat. It reuses the same template bindings through render_to_string and stores each result.
# billing/management/commands/generate_statements.py
# Django management command: python manage.py generate_statements --period "May 2026"
# Schedule it from cron, Celery beat, or your task runner once a month.
import os
import httpx
from django.core.management.base import BaseCommand
from django.template.loader import render_to_string
from billing.models import Account, Statement
PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf"
class Command(BaseCommand):
help = "Generate and store a monthly statement PDF for every active account."
def add_arguments(self, parser):
parser.add_argument("--period", required=True, help='e.g. "May 2026"')
def handle(self, *args, **options):
period = options["period"]
accounts = Account.objects.filter(status="active")
generated = 0
with httpx.Client(timeout=60) as client:
for account in accounts:
ledger = account.ledger_for_period(period)
# render_to_string uses your statement.html template with the
# same {{accountHolder.name}} / {{#each transactions}} bindings.
html = render_to_string(
"statement.html",
{
"companyName": "Acme Bank",
"accountHolder": {"name": account.holder_name},
"accountNumber": account.number,
"statementPeriod": period,
"openingBalance": ledger["opening_balance"],
"closingBalance": ledger["closing_balance"],
"transactions": ledger["transactions"],
},
)
try:
res = client.post(
PDFPIPE_URL,
headers={
"Authorization": f"Bearer {PDFPIPE_KEY}",
"Content-Type": "application/json",
},
json={
"html": html,
"filename": f"statement-{account.number}-{period.replace(' ', '-')}.pdf",
"store": True,
"options": {"format": "A4"},
},
)
res.raise_for_status()
document_url = res.json()["document_url"]
except httpx.HTTPError as exc:
self.stderr.write(f"Failed for {account.number}: {exc}")
continue
Statement.objects.create(
account=account,
period=period,
pdf_url=document_url,
)
generated += 1
self.stdout.write(self.style.SUCCESS(f"Generated {generated} statements for {period}."))Password-protect sensitive statements
Credit card and bank statements should not sit behind a guessable link alone. Set options.password to encrypt the file so the account holder must enter a password to open it, and combine it with store: true so you can deliver the link and the password through separate channels.
// statements/sensitive-statement.ts
// Credit card and bank statements carry sensitive data. Encrypt the file with a
// password so only the account holder can open it, and store it so the download
// link can be delivered separately from the password.
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: `cc-statement-${account.number}-${period}.pdf`,
store: true,
options: {
format: "A4",
// The opened PDF requires this password. Derive it from data the
// holder already knows (date of birth + last 4 digits is common),
// and never send it in the same message as the download link.
password: account.statementPassword,
},
}),
});
const { document_url, document_id, document_expires } = await res.json();
// Persist the link for the customer portal. The file stays available for the
// retention period of your plan, and the holder unlocks it with the password.
await db.statements.create({
accountId: account.id,
period,
pdfId: document_id,
pdfUrl: document_url,
expiresAt: new Date(document_expires),
});
// Deliver the link by email, and the password through a separate channel
// (SMS, secure portal message, or a phrase the holder already knows).
await sendEmail({
to: account.email,
subject: `Your ${period} card statement is ready`,
body: `Open your statement: ${document_url}\nThis file is password protected.`,
});Plans
| Plan | Statements/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 statements per month? Contact us for enterprise pricing.
Generate your first statement
Sign up, drop your statement template into one API call, and have a stored PDF back in seconds. Schedule the batch run when you are ready to do it for every account.