Skip to content

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

Create one schedule and PDFPipe renders the previous period for every account on the 1st of each month, in your time zone. No cron job to host.

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.

HTML template (Handlebars)
<!-- 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 scheduled run (Node.js)

Create a schedule once with POST /v1/schedules. On the 1st of each month PDFPipe renders every account's statement page, stores each PDF, and sends a signed webhook with the download links for you to email and save. One bad account is reported in the run result and never blocks the rest. Available on Starter and above; see scheduled batches.

TypeScript
// 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 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://billing.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://billing.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.

Django management command

If statements have to be assembled inside your own Django code instead, a management command is the natural home for it. 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.

Python (Django)
# 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.

TypeScript
// 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

PlanStatements/moStoragePrice
Hobby1001 dayFree
Starter3,00030 days$19/mo
Growth15,000365 days$49/mo
Scale50,0002 years$149/mo
Business100,0002 years$299/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. Add a schedule when you are ready to do it for every account.

Related guides