PDFPipe

Use case

Audit Report PDF API

GRC platforms, security tooling, and enterprise SaaS compliance modules need to produce audit reports that hold up to scrutiny: numbered findings, risk-rated color coding, executive summaries, and a permanent URL that can be linked from a compliance dashboard. One API call turns a server-rendered HTML template into a stored, professionally formatted PDF.

Access control review report (Node.js)

Build the report HTML on the server from your audit data: an executive summary block, a four-column scorecard with pass/fail counts, and a numbered findings list with risk badges color-coded red for critical, amber for medium, and green for low. Pass store: true so the returned document_url is permanent and can be linked directly from your compliance dashboard or emailed to the auditor.

TypeScript
// audit/generate-access-control-report.ts
const PDFPIPE_KEY = process.env.PDFPIPE_KEY!;
const PDFPIPE_URL = "https://api.pdfpipe.xyz/v1/pdf";

type RiskRating = "critical" | "high" | "medium" | "low";

type Finding = {
  id: number;
  title: string;
  description: string;
  risk: RiskRating;
  recommendation: string;
  owner: string;
  dueDate: string;
};

type AuditReportData = {
  reportId: string;
  reportTitle: string;
  auditScope: string;
  auditPeriod: string;
  preparedBy: string;
  reviewedBy: string;
  generatedAt: string;
  executiveSummary: string;
  totalControls: number;
  passedControls: number;
  failedControls: number;
  findings: Finding[];
};

const RISK_COLORS: Record<RiskRating, string> = {
  critical: "#7f1d1d",
  high:     "#991b1b",
  medium:   "#92400e",
  low:      "#166534",
};

const RISK_BG: Record<RiskRating, string> = {
  critical: "#fef2f2",
  high:     "#fff1f1",
  medium:   "#fffbeb",
  low:      "#f0fdf4",
};

function buildAuditReportHtml(data: AuditReportData): string {
  const passRate = Math.round((data.passedControls / data.totalControls) * 100);

  const findingRows = data.findings
    .map(
      (f) => `
      <div class="finding">
        <div class="finding-header">
          <span class="finding-num">Finding ${f.id}</span>
          <span class="risk-badge" style="color:${RISK_COLORS[f.risk]};background:${RISK_BG[f.risk]}">
            ${f.risk.toUpperCase()}
          </span>
        </div>
        <h3 class="finding-title">${f.title}</h3>
        <p class="finding-body">${f.description}</p>
        <div class="finding-meta">
          <div>
            <span class="meta-label">Recommendation</span>
            <p class="meta-value">${f.recommendation}</p>
          </div>
          <div class="meta-right">
            <span class="meta-label">Owner</span>
            <p class="meta-value">${f.owner}</p>
            <span class="meta-label" style="margin-top:8px">Due date</span>
            <p class="meta-value">${f.dueDate}</p>
          </div>
        </div>
      </div>`
    )
    .join("");

  return `<!DOCTYPE html>
<html>
<head>
<style>
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&display=swap');

  body {
    font-family: 'Arial', sans-serif;
    color: #1a1a1a;
    margin: 0;
    padding: 0;
  }
  .page { padding: 56px 64px; }

  /* Header / footer */
  .doc-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    padding-bottom: 20px;
    border-bottom: 2px solid #1a1a1a;
    margin-bottom: 36px;
  }
  .doc-brand { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
  .doc-meta  { font-size: 11px; color: #666; text-align: right; line-height: 1.7; }
  .doc-footer {
    margin-top: 48px;
    padding-top: 14px;
    border-top: 1px solid #e5e5e5;
    display: flex;
    justify-content: space-between;
    font-size: 10px;
    color: #999;
    font-family: 'IBM Plex Mono', monospace;
  }

  /* Title block */
  h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; }
  .subtitle { font-size: 13px; color: #555; margin-bottom: 28px; }

  /* Executive summary */
  .exec-summary {
    background: #f9f9f9;
    border-left: 4px solid #1a1a1a;
    padding: 18px 22px;
    margin-bottom: 32px;
    font-size: 13px;
    line-height: 1.65;
    color: #333;
  }
  .exec-summary-label {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    font-weight: 700;
    color: #999;
    margin-bottom: 8px;
  }

  /* Scorecard */
  .scorecard {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 10px;
    margin-bottom: 36px;
  }
  .score-cell {
    padding: 14px 16px;
    border: 1px solid #e5e5e5;
  }
  .score-label {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: #999;
    margin-bottom: 6px;
  }
  .score-value {
    font-size: 22px;
    font-weight: 700;
    font-family: 'IBM Plex Mono', monospace;
  }
  .score-pass  { color: #166534; }
  .score-fail  { color: #991b1b; }
  .score-total { color: #1a1a1a; }
  .score-rate  { color: #1d4ed8; }

  /* Section headings */
  .section-heading {
    font-size: 13px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: #999;
    border-bottom: 1px solid #e5e5e5;
    padding-bottom: 8px;
    margin-bottom: 20px;
    margin-top: 36px;
  }

  /* Finding cards */
  .finding {
    border: 1px solid #e5e5e5;
    padding: 20px 22px;
    margin-bottom: 14px;
    page-break-inside: avoid;
  }
  .finding-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 8px;
  }
  .finding-num {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-weight: 700;
    color: #999;
    font-family: 'IBM Plex Mono', monospace;
  }
  .risk-badge {
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.06em;
    padding: 2px 8px;
    border-radius: 2px;
    font-family: 'IBM Plex Mono', monospace;
  }
  .finding-title {
    font-size: 14px;
    font-weight: 600;
    margin: 0 0 8px;
  }
  .finding-body {
    font-size: 12px;
    line-height: 1.6;
    color: #444;
    margin: 0 0 14px;
  }
  .finding-meta {
    display: grid;
    grid-template-columns: 1fr 160px;
    gap: 16px;
    font-size: 12px;
  }
  .meta-right { display: flex; flex-direction: column; }
  .meta-label {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.07em;
    color: #999;
    margin-bottom: 2px;
    display: block;
  }
  .meta-value { margin: 0 0 4px; color: #1a1a1a; }

  @page { margin: 0; size: A4; }
</style>
</head>
<body>
  <div class="page">
    <div class="doc-header">
      <div>
        <div class="doc-brand">Internal Audit Report</div>
        <div style="font-size:12px;color:#666;margin-top:4px;">${data.reportTitle}</div>
      </div>
      <div class="doc-meta">
        Report ID: ${data.reportId}<br/>
        Scope: ${data.auditScope}<br/>
        Period: ${data.auditPeriod}<br/>
        Generated: ${data.generatedAt}
      </div>
    </div>

    <h1>${data.reportTitle}</h1>
    <p class="subtitle">
      Prepared by ${data.preparedBy} &bull; Reviewed by ${data.reviewedBy}
    </p>

    <div class="exec-summary">
      <div class="exec-summary-label">Executive Summary</div>
      ${data.executiveSummary}
    </div>

    <div class="scorecard">
      <div class="score-cell">
        <div class="score-label">Total Controls</div>
        <div class="score-value score-total">${data.totalControls}</div>
      </div>
      <div class="score-cell">
        <div class="score-label">Passed</div>
        <div class="score-value score-pass">${data.passedControls}</div>
      </div>
      <div class="score-cell">
        <div class="score-label">Failed</div>
        <div class="score-value score-fail">${data.failedControls}</div>
      </div>
      <div class="score-cell">
        <div class="score-label">Pass Rate</div>
        <div class="score-value score-rate">${passRate}%</div>
      </div>
    </div>

    <div class="section-heading">Findings (${data.findings.length})</div>
    ${findingRows}

    <div class="doc-footer">
      <span>CONFIDENTIAL — Internal Use Only</span>
      <span>Generated ${data.generatedAt} by automated compliance pipeline</span>
    </div>
  </div>
</body>
</html>`;
}

export async function generateAuditReport(data: AuditReportData): Promise<string> {
  const html = buildAuditReportHtml(data);

  const res = await fetch(PDFPIPE_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${PDFPIPE_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      filename: `audit-${data.reportId}-${data.auditPeriod.replace(/\s/g, "-")}.pdf`,
      store: true,
      options: { format: "A4" },
    }),
  });

  const { document_url } = await res.json();
  return document_url; // permanent URL, link from your compliance dashboard
}

Scheduled weekly generation (Python, asyncio)

Security teams run access control reviews on a recurring cadence. Wire a cron job (Monday 07:00 UTC) to generate a fresh report for every environment in parallel, then write each stored URL back to your GRC platform. Using asyncio.gather keeps all requests in flight concurrently so total wall time stays close to the slowest single document.

For monthly or quarterly cadences, swap the cron expression: monthly is 0 7 1 * *, quarterly requires a conditional check on the month number inside the handler.

Python (asyncio)
# audit/schedule_weekly_audit.py
# Runs every Monday at 07:00 UTC via cron: "0 7 * * 1"
# Generates an access control review PDF for each environment
# and posts the stored URL into your GRC platform.

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

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

def week_label() -> str:
    today = date.today()
    monday = today - timedelta(days=today.weekday())
    return monday.strftime("Week of %B %d, %Y")

def build_weekly_report_html(env: dict, week: str) -> str:
    rows = "".join(
        f'<tr>'
        f'<td>{ctrl["id"]}</td>'
        f'<td>{ctrl["name"]}</td>'
        f'<td style="color:{"#166534" if ctrl["status"]=="pass" else "#991b1b"};'
        f'font-weight:600">{ctrl["status"].upper()}</td>'
        f'<td style="font-family:monospace">{ctrl["last_checked"]}</td>'
        f'</tr>'
        for ctrl in env["controls"]
    )
    return f"""<!DOCTYPE html>
<html><head><style>
  body {{ font-family: Arial, sans-serif; color: #1a1a1a; padding: 48px 56px; }}
  h1  {{ font-size: 20px; font-weight: 700; margin-bottom: 4px; }}
  p.sub {{ font-size: 12px; color: #666; margin-bottom: 28px; }}
  table {{ width: 100%; border-collapse: collapse; font-size: 12px; }}
  th {{ text-align: left; padding: 8px 12px; font-size: 10px; text-transform: uppercase;
        letter-spacing: 0.07em; color: #999; border-bottom: 2px solid #1a1a1a; }}
  td {{ padding: 10px 12px; border-bottom: 1px solid #f0f0f0; }}
  tr:nth-child(even) td {{ background: #f9f9f9; }}
  .footer {{ margin-top: 32px; font-size: 10px; color: #aaa; font-family: monospace; }}
  @page {{ margin: 0; size: A4; }}
</style></head>
<body>
  <h1>Weekly Access Control Review</h1>
  <p class="sub">Environment: {env['name']} &bull; {week}</p>
  <table>
    <thead><tr><th>ID</th><th>Control</th><th>Status</th><th>Last Checked</th></tr></thead>
    <tbody>{rows}</tbody>
  </table>
  <p class="footer">Generated automatically. Report ID: {env['id']}-{week.replace(" ", "-")}</p>
</body></html>"""


async def run_weekly_audit():
    week = week_label()
    environments = await grc.environments.list(active=True)

    async with httpx.AsyncClient(timeout=60) as client:
        tasks = [
            client.post(
                PDFPIPE_URL,
                headers={
                    "Authorization": f"Bearer {PDFPIPE_KEY}",
                    "Content-Type": "application/json",
                },
                json={
                    "html": build_weekly_report_html(env, week),
                    "filename": f"access-control-{env['id']}-{week.replace(' ', '-')}.pdf",
                    "store": True,
                    "options": {"format": "A4"},
                },
            )
            for env in environments
        ]
        responses = await asyncio.gather(*tasks, return_exceptions=True)

    for env, resp in zip(environments, responses):
        if isinstance(resp, Exception):
            logger.error("Audit report failed", env_id=env["id"], error=str(resp))
            continue
        resp.raise_for_status()
        doc_url = resp.json()["document_url"]
        await grc.reports.create(
            environment_id=env["id"],
            week=week,
            report_type="access-control-review",
            document_url=doc_url,
        )

if __name__ == "__main__":
    asyncio.run(run_weekly_audit())

Batch compliance reports for all customers (TypeScript)

Enterprise SaaS platforms often offer tenants their own compliance reports as a value-add feature. At the close of each quarter, query all enterprise customers with compliance enabled, build the SOC 2 readiness HTML for each one, and send every document in a single batch request. Results come back in the same order as the input, so you can zip customers and URLs together and write each stored link back to the customer record in one pass.

The batch endpoint accepts up to 50 documents per call. For larger customer books, the runner below slices into chunks of 50 automatically.

TypeScript
// compliance/batch-soc2-reports.ts
// SaaS platform: generate a SOC 2 readiness report for every enterprise
// customer at the end of each quarter and write the stored URL back to
// the customer record so it appears in their compliance dashboard.

const PDFPIPE_KEY = process.env.PDFPIPE_KEY!;
const BATCH_URL   = "https://api.pdfpipe.xyz/v1/pdf/batch";

type ControlStatus = "implemented" | "partial" | "not-implemented";

type TrustServiceCriteria = {
  category: string;
  controls: { name: string; status: ControlStatus; evidence: string }[];
};

type CustomerAuditData = {
  customerId: string;
  companyName: string;
  quarter: string;
  criteria: TrustServiceCriteria[];
};

const STATUS_COLOR: Record<ControlStatus, string> = {
  "implemented":     "#166534",
  "partial":         "#92400e",
  "not-implemented": "#991b1b",
};

function buildSoc2Html(customer: CustomerAuditData): string {
  const sections = customer.criteria.map((cat) => {
    const rows = cat.controls
      .map(
        (ctrl) => `<tr>
          <td>${ctrl.name}</td>
          <td style="color:${STATUS_COLOR[ctrl.status]};font-weight:600">
            ${ctrl.status.replace(/-/g, " ").toUpperCase()}
          </td>
          <td class="evidence">${ctrl.evidence}</td>
        </tr>`
      )
      .join("");

    return `
      <h2 class="cat-heading">${cat.category}</h2>
      <table>
        <thead><tr>
          <th>Control</th><th>Status</th><th>Evidence</th>
        </tr></thead>
        <tbody>${rows}</tbody>
      </table>`;
  }).join("");

  return `<!DOCTYPE html>
<html><head><style>
  body  {{ font-family: Arial, sans-serif; color: #1a1a1a; padding: 56px 64px; }}
  .cover-header {{ border-bottom: 2px solid #1a1a1a; padding-bottom: 18px; margin-bottom: 32px; }}
  h1   {{ font-size: 22px; font-weight: 700; margin: 0 0 4px; }}
  .sub {{ font-size: 12px; color: #666; }}
  .cat-heading {{
    font-size: 12px; font-weight: 700; text-transform: uppercase;
    letter-spacing: 0.09em; color: #999; border-bottom: 1px solid #e5e5e5;
    padding-bottom: 8px; margin: 28px 0 14px;
  }}
  table {{ width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 8px; }}
  th {{ text-align: left; padding: 7px 10px; font-size: 10px; text-transform: uppercase;
        letter-spacing: 0.07em; color: #aaa; border-bottom: 1px solid #e0e0e0; }}
  td {{ padding: 9px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
  .evidence {{ font-size: 11px; color: #555; }}
  tr:nth-child(even) td {{ background: #f9f9f9; }}
  .footer {{ margin-top: 40px; font-size: 10px; color: #aaa; font-family: monospace;
             border-top: 1px solid #e5e5e5; padding-top: 12px; }}
  @page {{ margin: 0; size: A4; }}
</style></head>
<body>
  <div class="cover-header">
    <h1>SOC 2 Readiness Report</h1>
    <p class="sub">${customer.companyName} &bull; ${customer.quarter}</p>
  </div>
  ${sections}
  <div class="footer">
    CONFIDENTIAL — Generated for ${customer.companyName} | ${customer.quarter}
  </div>
</body></html>`;
}

export async function batchSoc2Reports(customers: CustomerAuditData[]): Promise<Record<string, string>> {
  // Build one document descriptor per customer
  const documents = customers.map((c) => ({
    html: buildSoc2Html(c),
    filename: `soc2-readiness-${c.customerId}-${c.quarter}.pdf`,
    store: true,           // permanent URL: link from the customer's dashboard
    options: { format: "A4" },
  }));

  // Batch endpoint: up to 50 documents per call
  const res = await fetch(BATCH_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${PDFPIPE_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ documents }),
  });

  if (!res.ok) throw new Error(`Batch failed: ${res.status}`);
  const { results } = await res.json();

  // Map customer IDs to stored document URLs
  const urlMap: Record<string, string> = {};
  for (let i = 0; i < customers.length; i++) {
    urlMap[customers[i].customerId] = results[i].document_url;
  }
  return urlMap;
}

// Quarterly runner: chunk into 50 if necessary
export async function runQuarterlyComplianceExport(quarter: string) {
  const customers: CustomerAuditData[] = await db.customers.findAll({
    plan: "enterprise",
    complianceEnabled: true,
  });

  const CHUNK = 50;
  for (let i = 0; i < customers.length; i += CHUNK) {
    const chunk = customers.slice(i, i + CHUNK);
    const urlMap = await batchSoc2Reports(chunk);

    // Persist document URLs back to each customer record
    await Promise.all(
      Object.entries(urlMap).map(([customerId, url]) =>
        db.customers.update(customerId, {
          [`soc2_report_url_${quarter}`]: url,
        })
      )
    );
  }
}

Audit report CSS tips

The CSS below covers the patterns that matter most for compliance documents: risk-rated color badges using CSS custom counter for auto-numbered findings, an executive summary box, a four-column scorecard, and consistent document header and footer blocks. No JavaScript charting library or external dependencies are required.

CSS
/* audit-report.css
   Styles for compliance and audit PDFs.
   Works without any external chart or JS library. */

/* Risk badge — inline usage: <span class="risk risk-high">HIGH</span> */
.risk {
  display: inline-block;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.06em;
  padding: 2px 8px;
  border-radius: 2px;
  font-family: 'IBM Plex Mono', monospace;
  text-transform: uppercase;
}
.risk-critical { color: #7f1d1d; background: #fef2f2; }
.risk-high     { color: #991b1b; background: #fff1f1; }
.risk-medium   { color: #92400e; background: #fffbeb; }
.risk-low      { color: #166534; background: #f0fdf4; }

/* Numbered finding: wrap each finding in <div class="finding"> */
.finding {
  border: 1px solid #e5e5e5;
  padding: 20px 22px;
  margin-bottom: 14px;
  page-break-inside: avoid;
  counter-increment: findings;
}
.finding::before {
  content: "Finding " counter(findings);
  display: block;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-weight: 700;
  color: #999;
  margin-bottom: 8px;
  font-family: 'IBM Plex Mono', monospace;
}
/* Reset counter on the findings container */
.findings-list { counter-reset: findings; }

/* Executive summary box */
.exec-summary {
  background: #f9f9f9;
  border-left: 4px solid #1a1a1a;
  padding: 18px 22px;
  font-size: 13px;
  line-height: 1.65;
  margin-bottom: 32px;
}

/* Four-column scorecard */
.scorecard {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 10px;
  margin-bottom: 36px;
}
.score-cell {
  padding: 14px 16px;
  border: 1px solid #e5e5e5;
}
.score-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: #999;
  margin-bottom: 6px;
}
.score-value {
  font-size: 22px;
  font-weight: 700;
  font-family: 'IBM Plex Mono', monospace;
}

/* Document-level header and footer */
.doc-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  border-bottom: 2px solid #1a1a1a;
  padding-bottom: 20px;
  margin-bottom: 36px;
}
.doc-footer {
  margin-top: 48px;
  border-top: 1px solid #e5e5e5;
  padding-top: 14px;
  display: flex;
  justify-content: space-between;
  font-size: 10px;
  color: #999;
  font-family: 'IBM Plex Mono', monospace;
}

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 →