PDFPipe

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.

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

Python (asyncio)
# 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 cron trigger (TypeScript)

Wire a cron job to run on the 1st of each month. The job fetches all active accounts, generates each statement, and emails the download link. Use Promise.allSettled so a single generation failure does not abort the rest of the batch.

TypeScript (node-cron)
// jobs/monthly-statements.ts
// Runs on the 1st of each month at 06:00 UTC.
// Works with node-cron (self-hosted) or Vercel Cron (vercel.json).
import cron from "node-cron";

// node-cron syntax: second(opt) minute hour day-of-month month day-of-week
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 = await db.accounts.findAll({ status: "active" });

  // generateAccountStatement is the function from the first snippet
  const results = await Promise.allSettled(
    accounts.map((account) =>
      generateAccountStatement({ ...account, statementPeriod: 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} account statement is ready`,
        body: `Download your statement: ${result.value}`,
      });
    } else {
      logger.error("Statement generation failed", {
        accountId: accounts[i].accountId,
        error: result.reason,
      });
    }
  }
});

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.

CSS
/* 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

PlanDocuments/moStoragePrice
Hobby5001 dayFree
Starter3,00030 days$19/mo
Growth15,000365 days$49/mo
Scale50,0002 years$149/mo
Business100,0002 years$499/mo

Need more than 100,000 documents per month? Contact us for enterprise pricing.

Try it free →