PDFPipe

Use case

Procurement PDF API

Procurement teams and SaaS platforms need professional PDFs at every step of the buying cycle: purchase orders sent to vendors, RFP documents distributed to bidders, vendor quotes compiled for approval, and audit-ready records attached to ERP transactions. Generating these from HTML templates means your documents always reflect live data, match your brand, and carry a permanent URL you can attach to any workflow.

Purchase order from an HTML template (Node.js / TypeScript)

A full PO template with a company header, vendor and ship-to address blocks, a shipping terms banner, a professional line items table with SKU, description, quantity, unit price, and totals, and a three-column approval signature block. Pass store: true to receive a permanent document_url you can attach to the PO record in your ERP, include in vendor email notifications, or store for audit trail purposes.

TypeScript
// handlers/generate-purchase-order.ts
import Mustache from "mustache";

// templates/purchase-order.html (excerpt)
const PO_TEMPLATE_HTML = `<!DOCTYPE html>
<html>
<head>
<style>
  @page {
    margin: 0.75in 1in;
    size: letter;
    @bottom-right {
      content: "Page " counter(page) " of " counter(pages);
      font-family: Arial, sans-serif;
      font-size: 9px;
      color: #666;
    }
  }
  body {
    font-family: Arial, sans-serif;
    font-size: 11pt;
    color: #1a1a1a;
    margin: 0;
    line-height: 1.4;
  }

  /* Header: logo left, PO meta right */
  .po-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-bottom: 32px;
    padding-bottom: 16px;
    border-bottom: 2px solid #1a1a1a;
  }
  .company-name {
    font-size: 20pt;
    font-weight: bold;
    letter-spacing: -0.02em;
  }
  .company-meta { font-size: 9pt; color: #555; margin-top: 4px; }
  .po-meta { text-align: right; }
  .po-number { font-size: 16pt; font-weight: bold; color: #1a1a1a; }
  .po-label {
    font-size: 8pt;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: #888;
    margin-bottom: 2px;
  }
  .po-date { font-size: 10pt; color: #555; margin-top: 6px; }

  /* Vendor / Ship-to block */
  .address-row {
    display: flex;
    gap: 48px;
    margin-bottom: 28px;
  }
  .address-block { flex: 1; }
  .address-label {
    font-size: 8pt;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: #888;
    margin-bottom: 6px;
  }
  .address-content { font-size: 10pt; line-height: 1.5; }
  .address-name { font-weight: bold; font-size: 11pt; }

  /* Shipping terms banner */
  .terms-banner {
    background: #f5f5f3;
    border: 1px solid #ddd;
    border-radius: 3px;
    padding: 10px 16px;
    margin-bottom: 24px;
    display: flex;
    gap: 40px;
    font-size: 9.5pt;
  }
  .terms-item { display: flex; flex-direction: column; }
  .terms-key { font-size: 8pt; text-transform: uppercase; letter-spacing: 0.08em; color: #888; }
  .terms-value { font-weight: bold; margin-top: 2px; }

  /* Line items table */
  .line-items {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: 4px;
  }
  .line-items thead tr {
    background: #1a1a1a;
    color: #fff;
  }
  .line-items th {
    font-size: 8.5pt;
    text-transform: uppercase;
    letter-spacing: 0.07em;
    padding: 9px 12px;
    text-align: left;
    font-weight: 600;
  }
  .line-items th.right,
  .line-items td.right { text-align: right; }
  .line-items tbody tr { border-bottom: 1px solid #e8e8e8; }
  .line-items tbody tr:nth-child(even) { background: #fafafa; }
  .line-items td {
    font-size: 10pt;
    padding: 10px 12px;
    vertical-align: top;
  }
  .line-items td.sku { font-family: monospace; font-size: 9pt; color: #555; }
  .line-items td.desc { max-width: 240px; }
  .line-items td.desc .desc-main { font-weight: 500; }
  .line-items td.desc .desc-sub { font-size: 8.5pt; color: #777; margin-top: 2px; }

  /* Totals block */
  .totals-wrapper {
    display: flex;
    justify-content: flex-end;
    margin-top: 0;
    margin-bottom: 32px;
  }
  .totals-table {
    width: 280px;
    border-collapse: collapse;
  }
  .totals-table td {
    font-size: 10pt;
    padding: 7px 12px;
    border-bottom: 1px solid #e8e8e8;
  }
  .totals-table td:last-child { text-align: right; }
  .totals-table .total-row td {
    font-size: 12pt;
    font-weight: bold;
    border-top: 2px solid #1a1a1a;
    border-bottom: none;
    padding-top: 10px;
  }

  /* Approval / signature block */
  .approval-section { margin-top: 40px; page-break-inside: avoid; }
  .approval-title {
    font-size: 9pt;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: #888;
    margin-bottom: 20px;
  }
  .approval-row { display: flex; gap: 48px; }
  .approval-col { flex: 1; }
  .sig-line {
    border-bottom: 1px solid #1a1a1a;
    height: 36px;
    margin-bottom: 6px;
  }
  .sig-label { font-size: 9pt; color: #666; }
  .sig-name { font-size: 10pt; font-weight: bold; margin-top: 3px; }
  .sig-title { font-size: 9pt; color: #555; }

  /* Notes */
  .notes-section { margin-top: 28px; }
  .notes-label {
    font-size: 9pt;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: #888;
    margin-bottom: 8px;
  }
  .notes-box {
    border: 1px solid #ddd;
    border-radius: 3px;
    padding: 12px 16px;
    font-size: 10pt;
    color: #444;
    line-height: 1.5;
  }
</style>
</head>
<body>

  <div class="po-header">
    <div>
      <div class="company-name">{{buyerName}}</div>
      <div class="company-meta">{{buyerAddress}}</div>
    </div>
    <div class="po-meta">
      <div class="po-label">Purchase Order</div>
      <div class="po-number">{{poNumber}}</div>
      <div class="po-date">Date: {{poDate}}</div>
    </div>
  </div>

  <div class="address-row">
    <div class="address-block">
      <div class="address-label">Vendor</div>
      <div class="address-content">
        <div class="address-name">{{vendorName}}</div>
        {{vendorAddress}}<br />
        Attn: {{vendorContact}}
      </div>
    </div>
    <div class="address-block">
      <div class="address-label">Ship To</div>
      <div class="address-content">
        <div class="address-name">{{shipToName}}</div>
        {{shipToAddress}}
      </div>
    </div>
  </div>

  <div class="terms-banner">
    <div class="terms-item">
      <span class="terms-key">Payment Terms</span>
      <span class="terms-value">{{paymentTerms}}</span>
    </div>
    <div class="terms-item">
      <span class="terms-key">Shipping Method</span>
      <span class="terms-value">{{shippingMethod}}</span>
    </div>
    <div class="terms-item">
      <span class="terms-key">Required By</span>
      <span class="terms-value">{{requiredByDate}}</span>
    </div>
    <div class="terms-item">
      <span class="terms-key">Requisition No.</span>
      <span class="terms-value">{{requisitionNumber}}</span>
    </div>
  </div>

  <table class="line-items">
    <thead>
      <tr>
        <th>#</th>
        <th>SKU / Part No.</th>
        <th>Description</th>
        <th class="right">Qty</th>
        <th class="right">Unit Price</th>
        <th class="right">Line Total</th>
      </tr>
    </thead>
    <tbody>
      {{#lineItems}}
      <tr>
        <td>{{lineNumber}}</td>
        <td class="sku">{{sku}}</td>
        <td class="desc">
          <div class="desc-main">{{description}}</div>
          {{#notes}}<div class="desc-sub">{{notes}}</div>{{/notes}}
        </td>
        <td class="right">{{quantity}} {{unit}}</td>
        <td class="right">{{unitPrice}}</td>
        <td class="right">{{lineTotal}}</td>
      </tr>
      {{/lineItems}}
    </tbody>
  </table>

  <div class="totals-wrapper">
    <table class="totals-table">
      <tr><td>Subtotal</td><td>{{subtotal}}</td></tr>
      <tr><td>Shipping &amp; Handling</td><td>{{shippingCost}}</td></tr>
      <tr><td>Tax ({{taxRate}})</td><td>{{taxAmount}}</td></tr>
      <tr class="total-row"><td>Total</td><td>{{totalAmount}}</td></tr>
    </table>
  </div>

  {{#notes}}
  <div class="notes-section">
    <div class="notes-label">Notes &amp; Special Instructions</div>
    <div class="notes-box">{{notes}}</div>
  </div>
  {{/notes}}

  <div class="approval-section">
    <div class="approval-title">Authorised By</div>
    <div class="approval-row">
      <div class="approval-col">
        <div class="sig-line"></div>
        <div class="sig-label">Signature</div>
        <div class="sig-name">{{approverName}}</div>
        <div class="sig-title">{{approverTitle}}</div>
      </div>
      <div class="approval-col">
        <div class="sig-line"></div>
        <div class="sig-label">Date Approved</div>
        <div class="sig-name">{{approvalDate}}</div>
      </div>
      <div class="approval-col">
        <div class="sig-line"></div>
        <div class="sig-label">Cost Centre</div>
        <div class="sig-name">{{costCentre}}</div>
      </div>
    </div>
  </div>

</body>
</html>`;

type LineItem = {
  lineNumber: number;
  sku: string;
  description: string;
  notes?: string;
  quantity: number;
  unit: string;
  unitPrice: string;
  lineTotal: string;
};

type PurchaseOrderData = {
  poNumber: string;
  requisitionNumber: string;
  requiredByDate: string;
  vendorName: string;
  vendorAddress: string;
  vendorContact: string;
  shipToName: string;
  shipToAddress: string;
  paymentTerms: string;
  shippingMethod: string;
  lineItems: LineItem[];
  subtotal: string;
  shippingCost: string;
  taxRate: string;
  taxAmount: string;
  totalAmount: string;
  approverName: string;
  approverTitle: string;
  approvalDate: string;
  costCentre: string;
  notes?: string;
};

export async function generatePurchaseOrder(data: PurchaseOrderData): Promise<string> {
  const html = Mustache.render(PO_TEMPLATE_HTML, {
    buyerName: "Acme Corporation",
    buyerAddress: "500 Technology Drive, Austin, TX 78701",
    poDate: new Date().toLocaleDateString("en-US", {
      month: "long", day: "numeric", year: "numeric",
    }),
    ...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: `po-${data.poNumber}.pdf`,
      store: true,
      options: { format: "Letter" },
    }),
  });

  const { document_url } = await res.json();
  // document_url is permanent: attach it to the PO record, email it to the vendor,
  // or store it in your ERP audit trail.
  return document_url;
}

Auto-generate on approval via webhook (Python)

A FastAPI endpoint that receives a po.approved event from a procurement platform (Coupa, Procurify, SAP Ariba, or a custom ERP), renders the PO as a PDF, and returns the permanent document URL so your system can email it to the vendor and attach it to the approval record in one step. No polling, no scheduled jobs: the PDF is generated the moment the approval fires.

Python (FastAPI)
# webhooks/procurement_handler.py
# Receives po.approved events from a procurement platform (e.g. Coupa, Procurify, SAP Ariba)
# and generates a stored PDF, returning the download URL for email dispatch to the vendor.

import os
import httpx
from fastapi import FastAPI, Request
from datetime import date

app = FastAPI()

PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf"

PO_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
  @page {{ margin: 0.75in 1in; size: letter; }}
  body {{ font-family: Arial, sans-serif; font-size: 11pt; color: #1a1a1a; margin: 0; }}
  .po-header {{
    display: flex; justify-content: space-between; align-items: flex-start;
    border-bottom: 2px solid #1a1a1a; padding-bottom: 16px; margin-bottom: 28px;
  }}
  .company-name {{ font-size: 18pt; font-weight: bold; }}
  .po-number {{ font-size: 14pt; font-weight: bold; text-align: right; }}
  .po-label {{ font-size: 8pt; text-transform: uppercase; letter-spacing: 0.1em; color: #888; }}
  .address-row {{ display: flex; gap: 48px; margin-bottom: 24px; }}
  .address-block {{ flex: 1; }}
  .address-label {{ font-size: 8pt; text-transform: uppercase; letter-spacing: 0.1em; color: #888; margin-bottom: 6px; }}
  .address-name {{ font-weight: bold; }}
  .line-items {{ width: 100%; border-collapse: collapse; margin-bottom: 4px; }}
  .line-items thead tr {{ background: #1a1a1a; color: #fff; }}
  .line-items th {{ font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.07em; padding: 9px 12px; text-align: left; }}
  .line-items th.right, .line-items td.right {{ text-align: right; }}
  .line-items tbody tr {{ border-bottom: 1px solid #e8e8e8; }}
  .line-items tbody tr:nth-child(even) {{ background: #fafafa; }}
  .line-items td {{ font-size: 10pt; padding: 9px 12px; }}
  .totals-wrapper {{ display: flex; justify-content: flex-end; margin-bottom: 28px; }}
  .totals-table {{ width: 260px; border-collapse: collapse; }}
  .totals-table td {{ font-size: 10pt; padding: 7px 12px; border-bottom: 1px solid #e8e8e8; }}
  .totals-table td:last-child {{ text-align: right; }}
  .totals-table .total-row td {{
    font-size: 12pt; font-weight: bold;
    border-top: 2px solid #1a1a1a; border-bottom: none; padding-top: 10px;
  }}
  .approval-section {{ margin-top: 36px; page-break-inside: avoid; }}
  .approval-row {{ display: flex; gap: 48px; }}
  .approval-col {{ flex: 1; }}
  .sig-line {{ border-bottom: 1px solid #1a1a1a; height: 36px; margin-bottom: 6px; }}
  .sig-label {{ font-size: 9pt; color: #666; }}
  .sig-name {{ font-size: 10pt; font-weight: bold; margin-top: 3px; }}
</style>
</head>
<body>
  <div class="po-header">
    <div>
      <div class="company-name">{buyer_name}</div>
      <div style="font-size:9pt;color:#555;margin-top:4px;">{buyer_address}</div>
    </div>
    <div>
      <div class="po-label">Purchase Order</div>
      <div class="po-number">{po_number}</div>
      <div style="font-size:9.5pt;color:#555;margin-top:4px;">Date: {po_date}</div>
    </div>
  </div>

  <div class="address-row">
    <div class="address-block">
      <div class="address-label">Vendor</div>
      <div class="address-name">{vendor_name}</div>
      <div style="font-size:10pt;">{vendor_address}</div>
    </div>
    <div class="address-block">
      <div class="address-label">Payment Terms</div>
      <div style="font-weight:bold;font-size:10pt;margin-top:6px;">{payment_terms}</div>
    </div>
  </div>

  <table class="line-items">
    <thead>
      <tr>
        <th>SKU</th><th>Description</th>
        <th class="right">Qty</th>
        <th class="right">Unit Price</th>
        <th class="right">Total</th>
      </tr>
    </thead>
    <tbody>
      {line_items_html}
    </tbody>
  </table>

  <div class="totals-wrapper">
    <table class="totals-table">
      <tr><td>Subtotal</td><td>{subtotal}</td></tr>
      <tr><td>Tax</td><td>{tax_amount}</td></tr>
      <tr class="total-row"><td>Total</td><td>{total_amount}</td></tr>
    </table>
  </div>

  <div class="approval-section">
    <div class="approval-row">
      <div class="approval-col">
        <div class="sig-line"></div>
        <div class="sig-label">Approved By</div>
        <div class="sig-name">{approver_name}</div>
        <div class="sig-label">{approver_title}</div>
      </div>
      <div class="approval-col">
        <div class="sig-line"></div>
        <div class="sig-label">Date Approved</div>
        <div class="sig-name">{approval_date}</div>
      </div>
    </div>
  </div>
</body>
</html>"""


def build_line_items_html(items: list[dict]) -> str:
    rows = []
    for item in items:
        rows.append(
            f"""<tr>
              <td style="font-family:monospace;font-size:9pt;color:#555;">{item['sku']}</td>
              <td>{item['description']}</td>
              <td class="right">{item['quantity']} {item.get('unit', 'ea')}</td>
              <td class="right">{item['unit_price']}</td>
              <td class="right">{item['line_total']}</td>
            </tr>"""
        )
    return "\n".join(rows)


@app.post("/webhooks/procurement")
async def procurement_webhook(request: Request):
    payload = await request.json()

    event_type = payload.get("event")
    if event_type != "po.approved":
        return {"status": "ignored"}

    po = payload["data"]
    po_number = po["po_number"]

    html = PO_TEMPLATE.format(
        buyer_name=po["buyer"]["name"],
        buyer_address=po["buyer"]["address"],
        po_number=po_number,
        po_date=date.today().strftime("%B %d, %Y"),
        vendor_name=po["vendor"]["name"],
        vendor_address=po["vendor"]["address"],
        payment_terms=po.get("payment_terms", "Net 30"),
        line_items_html=build_line_items_html(po["line_items"]),
        subtotal=po["subtotal"],
        tax_amount=po.get("tax_amount", "$0.00"),
        total_amount=po["total_amount"],
        approver_name=po["approver"]["name"],
        approver_title=po["approver"]["title"],
        approval_date=date.today().strftime("%B %d, %Y"),
    )

    response = httpx.post(
        PDFPIPE_URL,
        headers={
            "Authorization": f"Bearer {PDFPIPE_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "html": html,
            "filename": f"po-{po_number}.pdf",
            "store": True,
            "options": {"format": "Letter"},
        },
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()

    # document_url is permanent: attach to email notification, ERP record, or audit trail
    return {
        "status": "ok",
        "po_number": po_number,
        "document_url": data["document_url"],
        "document_id": data["document_id"],
    }

End-of-month PO summary reports for finance (Python, asyncio)

At month-end, finance teams need a consolidated PO summary per cost centre for budget reconciliation and audit. Use the batch endpoint to generate one report per cost centre in a single HTTP call rather than looping over individual requests. Each report is stored with a permanent URL for the finance team, external auditors, or your document management system.

Python
# scripts/monthly_po_summary.py
# End-of-month: generate a PO summary report per cost centre using the batch endpoint.
# Each report covers all POs approved in the billing month and is stored with a
# permanent URL for the finance team and auditors.

import os
import asyncio
import httpx
from datetime import date, timedelta
from calendar import monthrange

PDFPIPE_KEY = os.environ["PDFPIPE_KEY"]
BATCH_URL = "https://api.pdfpipe.xyz/v1/pdf/batch"

SUMMARY_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
  @page {{
    margin: 0.75in 1in;
    size: letter;
    @bottom-right {{
      content: "Page " counter(page) " of " counter(pages);
      font-family: Arial, sans-serif; font-size: 9px; color: #666;
    }}
  }}
  body {{ font-family: Arial, sans-serif; font-size: 10.5pt; color: #1a1a1a; margin: 0; }}
  .report-header {{
    border-bottom: 2px solid #1a1a1a; padding-bottom: 16px; margin-bottom: 28px;
    display: flex; justify-content: space-between; align-items: flex-end;
  }}
  .report-title {{ font-size: 16pt; font-weight: bold; }}
  .report-sub {{ font-size: 10pt; color: #555; margin-top: 4px; }}
  .report-meta {{ text-align: right; font-size: 9pt; color: #666; line-height: 1.7; }}
  .summary-table {{ width: 100%; border-collapse: collapse; margin-bottom: 6px; }}
  .summary-table thead tr {{ background: #1a1a1a; color: #fff; }}
  .summary-table th {{
    font-size: 8.5pt; text-transform: uppercase; letter-spacing: 0.07em;
    padding: 9px 12px; text-align: left;
  }}
  .summary-table th.right, .summary-table td.right {{ text-align: right; }}
  .summary-table tbody tr {{ border-bottom: 1px solid #e8e8e8; }}
  .summary-table tbody tr:nth-child(even) {{ background: #fafafa; }}
  .summary-table td {{ font-size: 10pt; padding: 9px 12px; }}
  .summary-table td.po-num {{ font-family: monospace; font-size: 9pt; color: #555; }}
  .summary-table td.status-approved {{
    color: #1a6e2e; font-weight: bold; font-size: 9pt;
  }}
  .totals-footer {{
    display: flex; justify-content: flex-end; margin-top: 4px;
  }}
  .totals-footer table {{ width: 260px; border-collapse: collapse; }}
  .totals-footer td {{ font-size: 10pt; padding: 7px 12px; border-bottom: 1px solid #e8e8e8; }}
  .totals-footer td:last-child {{ text-align: right; }}
  .totals-footer .grand-total td {{
    font-size: 12pt; font-weight: bold;
    border-top: 2px solid #1a1a1a; border-bottom: none; padding-top: 10px;
  }}
</style>
</head>
<body>
  <div class="report-header">
    <div>
      <div class="report-title">Purchase Order Summary</div>
      <div class="report-sub">Cost Centre: {cost_centre_name} &bull; {period_label}</div>
    </div>
    <div class="report-meta">
      Generated: {generated_date}<br />
      Total POs: {total_pos}<br />
      Prepared by: Finance Operations
    </div>
  </div>

  <table class="summary-table">
    <thead>
      <tr>
        <th>PO Number</th>
        <th>Vendor</th>
        <th>Description</th>
        <th>Approved By</th>
        <th>Approval Date</th>
        <th class="right">Amount</th>
        <th>Status</th>
      </tr>
    </thead>
    <tbody>
      {rows_html}
    </tbody>
  </table>

  <div class="totals-footer">
    <table>
      <tr><td>Total Approved</td><td>{period_total}</td></tr>
      <tr><td>Open / Pending Delivery</td><td>{open_total}</td></tr>
      <tr class="grand-total"><td>Grand Total</td><td>{grand_total}</td></tr>
    </table>
  </div>
</body>
</html>"""


def build_rows_html(pos: list[dict]) -> str:
    rows = []
    for po in pos:
        rows.append(
            f"""<tr>
              <td class="po-num">{po['po_number']}</td>
              <td>{po['vendor_name']}</td>
              <td>{po['description']}</td>
              <td>{po['approver_name']}</td>
              <td>{po['approval_date']}</td>
              <td class="right">{po['total_amount']}</td>
              <td class="status-approved">Approved</td>
            </tr>"""
        )
    return "\n".join(rows)


def build_summary_html(cost_centre: dict, period_label: str) -> str:
    return SUMMARY_TEMPLATE.format(
        cost_centre_name=cost_centre["name"],
        period_label=period_label,
        generated_date=date.today().strftime("%B %d, %Y"),
        total_pos=len(cost_centre["pos"]),
        rows_html=build_rows_html(cost_centre["pos"]),
        period_total=cost_centre["period_total"],
        open_total=cost_centre["open_total"],
        grand_total=cost_centre["grand_total"],
    )


async def generate_monthly_summaries(cost_centres: list[dict]) -> list[dict]:
    """
    Generate one summary report per cost centre in a single batch call.
    Up to 50 documents per call. For larger organisations, chunk into groups of 50.
    """
    today = date.today()
    first_of_month = today.replace(day=1)
    last_month_end = first_of_month - timedelta(days=1)
    period_label = last_month_end.strftime("%B %Y")

    documents = [
        {
            "html": build_summary_html(cc, period_label),
            "filename": f"po-summary-{cc['id']}-{last_month_end.strftime('%Y-%m')}.pdf",
            "store": True,
            "options": {"format": "Letter"},
        }
        for cc in cost_centres
    ]

    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()
        data = response.json()

    results = []
    for i, cc in enumerate(cost_centres):
        results.append({
            "cost_centre_id": cc["id"],
            "cost_centre_name": cc["name"],
            "document_url": data["results"][i]["document_url"],
            "document_id": data["results"][i]["document_id"],
        })

    return results


if __name__ == "__main__":
    cost_centres = load_cost_centres_with_monthly_pos()  # returns list of cost centre dicts
    results = asyncio.run(generate_monthly_summaries(cost_centres))

    for r in results:
        print(f"{r['cost_centre_name']}: {r['document_url']}")

CSS for procurement documents

Procurement PDFs need tighter page margins than legal documents, a flex header that positions your company logo left and the PO number right, a dark header row on the line items table, zebra striping for readability across long item lists, a visually distinct totals row, and an approval signature block that never splits across pages. Use @page { @top-left { content: ... } } to add a running header with the company name on continuation pages, and suppress it on the first page with @page :first.

CSS
<!-- Procurement document CSS: professional table, header/logo block, approval signature -->
<style>
  /* Page: tighter margins than legal docs, US Letter */
  @page {
    margin: 0.75in 1in;
    size: letter;

    /* Page number bottom-right */
    @bottom-right {
      content: "Page " counter(page) " of " counter(pages);
      font-family: Arial, sans-serif;
      font-size: 9px;
      color: #666;
    }

    /* Company name top-left on continuation pages */
    @top-left {
      content: "Acme Corporation — Purchase Order";
      font-family: Arial, sans-serif;
      font-size: 9px;
      color: #888;
    }
  }

  /* Suppress running header on the first page */
  @page :first {
    @top-left { content: none; }
  }

  body {
    font-family: Arial, sans-serif;
    font-size: 11pt;
    line-height: 1.4;
    color: #1a1a1a;
    margin: 0;
  }

  /* Dark header row for the line items table */
  .line-items thead tr {
    background: #1a1a1a;
    color: #fff;
  }

  /* Zebra striping for line item rows */
  .line-items tbody tr:nth-child(even) { background: #fafafa; }
  .line-items tbody tr       { border-bottom: 1px solid #e8e8e8; }

  /* Bold totals row, separated by a heavier border */
  .total-row td {
    font-size: 12pt;
    font-weight: bold;
    border-top: 2px solid #1a1a1a;
  }

  /* Keep the approval block on one page */
  .approval-section { page-break-inside: avoid; }

  /* Signature lines */
  .sig-line {
    border-bottom: 1px solid #1a1a1a;
    height: 36px;
    margin-bottom: 6px;
  }

  /* Logo positioning: use a flex header so the logo sits left
     and the PO number sits right regardless of content length */
  .po-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    border-bottom: 2px solid #1a1a1a;
    padding-bottom: 16px;
    margin-bottom: 28px;
  }

  /* If you embed a logo image instead of text, size it here */
  .company-logo {
    max-height: 48px;
    width: auto;
  }
</style>

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 →