PDFPipe

Use case

Insurance PDF API

Insurance documents need to be precise, professional, and instantly available. Policy documents, certificates of insurance, and explanations of benefits are generated by the hundreds daily in modern insurtech platforms. Each one is triggered by a policy or claims event and must be stored, retrievable, and formatted for the carrier, the insured, and any certificate holders.

Certificate of Insurance (COI)

A COI must carry the policy number, insured name, coverage types (GL, auto, workers comp), effective and expiration dates, insurer details, producer details, and an additional insured block. The template below covers all of these fields. Pass store: true and the returned document_url can be emailed to the certificate holder or embedded in your client portal immediately.

TypeScript
// handlers/generate-coi.ts
// Certificate of Insurance generation for GL, auto, and workers comp policies
import Mustache from "mustache";
import { readFileSync } from "fs";

const template = readFileSync("./templates/coi.html", "utf-8");

// templates/coi.html (abbreviated for clarity)
const COI_HTML_TEMPLATE = `<!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: 32px; }
  .title { font-size: 20px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
  .subtitle { font-size: 11px; color: #666; margin-top: 4px; }
  .insurer-block { text-align: right; font-size: 12px; color: #444; }
  .insurer-block strong { display: block; font-size: 14px; color: #1a1a1a; }
  .section-label { font-size: 10px; font-weight: 700; text-transform: uppercase;
    letter-spacing: 0.08em; color: #999; border-bottom: 1px solid #e5e5e5;
    padding-bottom: 6px; margin: 24px 0 12px; }
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 32px; }
  .field { margin-bottom: 8px; }
  .field-label { font-size: 10px; color: #999; text-transform: uppercase;
    letter-spacing: 0.06em; margin-bottom: 2px; }
  .field-value { font-size: 13px; font-weight: 500; color: #1a1a1a; }
  .coverage-table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 12px; }
  .coverage-table th { text-align: left; font-size: 10px; text-transform: uppercase;
    letter-spacing: 0.07em; color: #999; padding: 6px 8px;
    border-bottom: 2px solid #e5e5e5; background: #fafafa; }
  .coverage-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; color: #333; }
  .coverage-table td.policy-no { font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
  .checkbox { display: inline-block; width: 12px; height: 12px; border: 1px solid #999;
    margin-right: 6px; vertical-align: middle; text-align: center; font-size: 9px;
    line-height: 12px; }
  .checkbox.checked { background: #1a1a1a; color: #fff; }
  .additional-insured { background: #f9f9f9; border-left: 3px solid #d23a1d;
    padding: 14px 18px; margin: 20px 0; font-size: 12px; }
  .producer { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 28px; }
  .producer-block { border: 1px solid #e5e5e5; padding: 14px 16px; }
  .sig-line { border-bottom: 1px solid #1a1a1a; height: 28px; margin-top: 16px; }
  .sig-label { font-size: 11px; color: #999; margin-top: 4px; }
  @page { size: Letter; margin: 0; }
</style>
</head>
<body>
  <div class="header">
    <div>
      <div class="title">Certificate of Insurance</div>
      <div class="subtitle">This certificate is issued as a matter of information only and confers no rights upon the certificate holder.</div>
    </div>
    <div class="insurer-block">
      <strong>{{insurerName}}</strong>
      {{insurerAddress}}<br>{{insurerCityStateZip}}<br>NAIC # {{naicNumber}}
    </div>
  </div>

  <div class="section-label">Insured</div>
  <div class="grid-2">
    <div class="field">
      <div class="field-label">Insured Name</div>
      <div class="field-value">{{insuredName}}</div>
    </div>
    <div class="field">
      <div class="field-label">Insured Address</div>
      <div class="field-value">{{insuredAddress}}</div>
    </div>
    <div class="field">
      <div class="field-label">Policy Number</div>
      <div class="field-value" style="font-family:monospace">{{policyNumber}}</div>
    </div>
    <div class="field">
      <div class="field-label">Effective / Expiration</div>
      <div class="field-value">{{effectiveDate}} &ndash; {{expirationDate}}</div>
    </div>
  </div>

  <div class="section-label">Coverages</div>
  <table class="coverage-table">
    <thead>
      <tr>
        <th>Type of Insurance</th>
        <th>Policy Number</th>
        <th>Effective Date</th>
        <th>Expiration Date</th>
        <th>Limits</th>
      </tr>
    </thead>
    <tbody>
      {{#coverages}}
      <tr>
        <td>{{type}}</td>
        <td class="policy-no">{{policyNumber}}</td>
        <td>{{effectiveDate}}</td>
        <td>{{expirationDate}}</td>
        <td>{{limits}}</td>
      </tr>
      {{/coverages}}
    </tbody>
  </table>

  {{#additionalInsured}}
  <div class="additional-insured">
    <span class="checkbox checked">&#10003;</span>
    <strong>Additional Insured:</strong> {{additionalInsuredName}} is included as additional insured
    under the General Liability policy above.
  </div>
  {{/additionalInsured}}

  <div class="producer">
    <div class="producer-block">
      <div class="field-label">Producer</div>
      <div class="field-value">{{producerName}}</div>
      <div style="font-size:12px;color:#555;margin-top:4px">{{producerAddress}}<br>{{producerPhone}}</div>
    </div>
    <div class="producer-block">
      <div class="field-label">Certificate Holder</div>
      <div class="field-value">{{holderName}}</div>
      <div style="font-size:12px;color:#555;margin-top:4px">{{holderAddress}}</div>
      <div class="sig-line"></div>
      <div class="sig-label">Authorised Representative</div>
    </div>
  </div>
</body>
</html>`;

type CoverageRow = {
  type: string;
  policyNumber: string;
  effectiveDate: string;
  expirationDate: string;
  limits: string;
};

type COIPayload = {
  policyId: string;
  policyNumber: string;
  insurerName: string;
  insurerAddress: string;
  insurerCityStateZip: string;
  naicNumber: string;
  insuredName: string;
  insuredAddress: string;
  effectiveDate: string;
  expirationDate: string;
  coverages: CoverageRow[];
  additionalInsured?: boolean;
  additionalInsuredName?: string;
  producerName: string;
  producerAddress: string;
  producerPhone: string;
  holderName: string;
  holderAddress: string;
};

export async function generateCOI(payload: COIPayload): Promise<string> {
  const html = Mustache.render(COI_HTML_TEMPLATE, payload);

  const pdfRes = 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: `coi-${payload.policyId}.pdf`,
      store: true,
      options: { format: "Letter" },
    }),
  });

  const { document_url, document_id } = await pdfRes.json();

  // Persist the COI URL in your policy management system
  await db.policies.update(payload.policyId, {
    coiPdfUrl: document_url,
    coiPdfId: document_id,
  });

  return document_url;
}

Policy issuance webhook (Python)

When Applied Epic, Guidewire, or your internal policy management system fires a policy.issued event, extract the policy data, render the full policy document HTML, call the PDF API, then write the returned document_url back to the policy record and email it to the insured. The entire round-trip takes under two seconds on Growth plans.

Python
# webhooks/policy_issued.py
# Triggered by Applied Epic, Guidewire, or any insurance platform "policy.issued" event
import os
import httpx
from datetime import datetime

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

POLICY_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<style>
  body {{ font-family: 'Inter', sans-serif; color: #1a1a1a; padding: 56px 64px; }}
  .header {{ display: flex; justify-content: space-between; margin-bottom: 40px; }}
  .logo-text {{ font-size: 22px; font-weight: 700; color: #d23a1d; }}
  .doc-meta {{ font-size: 12px; color: #666; text-align: right; }}
  h1 {{ font-size: 20px; font-weight: 700; margin-bottom: 6px; }}
  .policy-no {{ font-family: monospace; font-size: 13px; color: #666; margin-bottom: 32px; }}
  h2 {{ font-size: 13px; font-weight: 700; text-transform: uppercase;
       letter-spacing: 0.07em; margin: 28px 0 10px; }}
  p, li {{ font-size: 13px; line-height: 1.75; color: #333; }}
  .declarations {{ background: #f9f9f9; padding: 20px 24px; margin-bottom: 28px; }}
  .declarations table {{ width: 100%; border-collapse: collapse; }}
  .declarations td {{ font-size: 13px; padding: 6px 0; }}
  .declarations td:first-child {{ color: #666; width: 240px; }}
  .declarations td:last-child {{ font-weight: 500; }}
  .page-break {{ page-break-before: always; }}
  .footer-note {{ margin-top: 40px; font-size: 11px; color: #999; border-top: 1px solid #e5e5e5; padding-top: 12px; }}
  @page {{ size: Letter; margin: 1in; }}
  @page {{ @bottom-right {{ content: "Page " counter(page) " of " counter(pages);
    font-size: 10px; color: #999; }} }}
</style>
</head>
<body>
  <div class="header">
    <div class="logo-text">{carrier_name}</div>
    <div class="doc-meta">
      Issued: {issue_date}<br>
      Policy Period: {effective_date} to {expiration_date}
    </div>
  </div>

  <h1>Commercial General Liability Policy</h1>
  <div class="policy-no">Policy No: {policy_number}</div>

  <div class="declarations">
    <table>
      <tr><td>Named Insured</td><td>{insured_name}</td></tr>
      <tr><td>Insured Address</td><td>{insured_address}</td></tr>
      <tr><td>Coverage Type</td><td>{coverage_type}</td></tr>
      <tr><td>Each Occurrence Limit</td><td>{occurrence_limit}</td></tr>
      <tr><td>General Aggregate Limit</td><td>{aggregate_limit}</td></tr>
      <tr><td>Products / Completed Ops</td><td>{products_limit}</td></tr>
      <tr><td>Annual Premium</td><td>{annual_premium}</td></tr>
    </table>
  </div>

  <h2>1. Insuring Agreement</h2>
  <p>
    We will pay those sums that the insured becomes legally obligated to pay as damages
    because of bodily injury or property damage to which this insurance applies.
    We will have the right and duty to defend the insured against any suit seeking those damages.
  </p>

  <h2>2. Conditions</h2>
  <p>
    This policy is subject to all terms, conditions, exclusions, and endorsements attached hereto,
    which are incorporated by reference. Refer to the full policy form for complete terms.
  </p>

  <div class="footer-note">
    This document is a summary of coverage. It does not alter, amend, extend, or modify
    the terms of the policy. Retain this document for the full policy period.
    Policy No: {policy_number}.
  </div>
</body>
</html>
"""

def handle_policy_issued(event: dict) -> None:
    """
    Webhook handler for policy.issued events from Applied Epic, Guidewire,
    or any insurance platform that fires webhooks on policy creation.
    Generates the full policy document, stores it, and emails the insured.
    """
    policy = event["policy"]

    html = POLICY_TEMPLATE.format(
        carrier_name=policy["carrier_name"],
        issue_date=datetime.now().strftime("%B %d, %Y"),
        effective_date=policy["effective_date"],
        expiration_date=policy["expiration_date"],
        policy_number=policy["policy_number"],
        insured_name=policy["insured_name"],
        insured_address=policy["insured_address"],
        coverage_type=policy["coverage_type"],
        occurrence_limit=policy["occurrence_limit"],
        aggregate_limit=policy["aggregate_limit"],
        products_limit=policy["products_limit"],
        annual_premium=policy["annual_premium"],
    )

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

    # Write the document URL back to the policy record
    db.policies.update(
        policy["policy_id"],
        {
            "document_url": data["document_url"],
            "document_id": data["document_id"],
        },
    )

    # Email the insured their policy document
    send_email(
        to=policy["insured_email"],
        subject=f"Your policy document: {policy['policy_number']}",
        body=(
            f"Dear {policy['insured_name']},\n\n"
            f"Your {policy['coverage_type']} policy has been issued. "
            f"Download your policy document at the link below.\n\n"
            f"Download: {data['document_url']}\n\n"
            f"Policy Number: {policy['policy_number']}\n"
            f"Coverage Period: {policy['effective_date']} to {policy['expiration_date']}"
        ),
    )

Explanation of Benefits batch (Python asyncio)

After a nightly claims run, generate EOB PDFs for every claimant concurrently using asyncio.gather. Each task calls the PDF API independently and writes the resulting document_url back to the claims record. Member services and the insured portal can then surface the correct EOB without any additional generation step.

Python
# claims/eob_batch.py
# After a nightly claims run, generate EOB PDFs for all claimants in one async batch
import asyncio
import os
import httpx

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

EOB_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<style>
  body {{ font-family: 'Inter', sans-serif; color: #1a1a1a; padding: 48px 56px; }}
  .header {{ display: flex; justify-content: space-between; margin-bottom: 32px; }}
  .logo-text {{ font-size: 20px; font-weight: 700; color: #d23a1d; }}
  .eob-meta {{ font-size: 12px; color: #666; text-align: right; }}
  h1 {{ font-size: 18px; font-weight: 700; margin-bottom: 4px; }}
  .claim-no {{ font-family: monospace; font-size: 12px; color: #666; margin-bottom: 28px; }}
  .member-block {{ display: grid; grid-template-columns: 1fr 1fr; gap: 8px 32px;
    background: #f9f9f9; padding: 16px 20px; margin-bottom: 24px; font-size: 13px; }}
  .member-block .label {{ color: #999; font-size: 11px; text-transform: uppercase;
    letter-spacing: 0.06em; }}
  .service-table {{ width: 100%; border-collapse: collapse; font-size: 12px; margin: 16px 0; }}
  .service-table th {{ font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em;
    color: #999; padding: 8px; border-bottom: 2px solid #e5e5e5;
    text-align: right; background: #fafafa; }}
  .service-table th:first-child, .service-table th:nth-child(2) {{ text-align: left; }}
  .service-table td {{ padding: 10px 8px; border-bottom: 1px solid #f0f0f0;
    color: #333; text-align: right; }}
  .service-table td:first-child, .service-table td:nth-child(2) {{ text-align: left; }}
  .totals {{ margin-top: 4px; border-top: 2px solid #1a1a1a; }}
  .totals td {{ padding: 10px 8px; font-weight: 600; font-size: 13px; text-align: right; }}
  .totals td:first-child {{ text-align: left; }}
  .summary-box {{ border: 1px solid #e5e5e5; padding: 16px 20px; margin-top: 24px;
    display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; font-size: 13px; }}
  .summary-box .label {{ font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
    color: #999; margin-bottom: 4px; }}
  .summary-box .amount {{ font-size: 18px; font-weight: 700; color: #1a1a1a; }}
  .notice {{ margin-top: 24px; font-size: 11px; color: #999; border-top: 1px solid #e5e5e5;
    padding-top: 12px; line-height: 1.6; }}
  @page {{ size: Letter; margin: 1in; }}
</style>
</head>
<body>
  <div class="header">
    <div class="logo-text">{plan_name}</div>
    <div class="eob-meta">EOB Date: {eob_date}<br>Group No: {group_number}</div>
  </div>

  <h1>Explanation of Benefits</h1>
  <div class="claim-no">Claim No: {claim_number}</div>

  <div class="member-block">
    <div>
      <div class="label">Member Name</div>
      <div>{member_name}</div>
    </div>
    <div>
      <div class="label">Member ID</div>
      <div style="font-family:monospace">{member_id}</div>
    </div>
    <div>
      <div class="label">Date of Service</div>
      <div>{service_date}</div>
    </div>
    <div>
      <div class="label">Provider</div>
      <div>{provider_name}</div>
    </div>
  </div>

  <table class="service-table">
    <thead>
      <tr>
        <th>Service</th>
        <th>Code</th>
        <th>Billed</th>
        <th>Allowed</th>
        <th>Plan Paid</th>
        <th>Your Responsibility</th>
      </tr>
    </thead>
    <tbody>
      {service_rows}
    </tbody>
    <tfoot class="totals">
      <tr>
        <td colspan="2">Totals</td>
        <td>{total_billed}</td>
        <td>{total_allowed}</td>
        <td>{total_plan_paid}</td>
        <td>{total_member_owes}</td>
      </tr>
    </tfoot>
  </table>

  <div class="summary-box">
    <div>
      <div class="label">Plan Paid</div>
      <div class="amount">{total_plan_paid}</div>
    </div>
    <div>
      <div class="label">Your Deductible Applied</div>
      <div class="amount">{deductible_applied}</div>
    </div>
    <div>
      <div class="label">Amount You Owe</div>
      <div class="amount">{total_member_owes}</div>
    </div>
  </div>

  <div class="notice">
    This is not a bill. If you have questions about this explanation of benefits,
    call the member services number on the back of your insurance card.
    You have the right to appeal this determination within 180 days of receipt.
  </div>
</body>
</html>
"""

def build_service_rows(services: list[dict]) -> str:
    rows = []
    for s in services:
        rows.append(
            f"<tr>"
            f"<td>{s['description']}</td>"
            f"<td style='font-family:monospace'>{s['code']}</td>"
            f"<td>{s['billed']}</td>"
            f"<td>{s['allowed']}</td>"
            f"<td>{s['plan_paid']}</td>"
            f"<td>{s['member_owes']}</td>"
            f"</tr>"
        )
    return "".join(rows)

async def generate_eob_for_claim(client: httpx.AsyncClient, claim: dict) -> tuple[str, str]:
    """Generate a single EOB PDF for one claimant. Returns (claim_id, document_url)."""
    html = EOB_TEMPLATE.format(
        plan_name=claim["plan_name"],
        eob_date=claim["eob_date"],
        group_number=claim["group_number"],
        claim_number=claim["claim_number"],
        member_name=claim["member_name"],
        member_id=claim["member_id"],
        service_date=claim["service_date"],
        provider_name=claim["provider_name"],
        service_rows=build_service_rows(claim["services"]),
        total_billed=claim["total_billed"],
        total_allowed=claim["total_allowed"],
        total_plan_paid=claim["total_plan_paid"],
        total_member_owes=claim["total_member_owes"],
        deductible_applied=claim["deductible_applied"],
    )

    response = await client.post(
        PDFPIPE_URL,
        headers={
            "Authorization": f"Bearer {PDFPIPE_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "html": html,
            "filename": f"eob-{claim['claim_number']}.pdf",
            "store": True,
            "options": {"format": "Letter"},
        },
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()
    return claim["claim_id"], data["document_url"]

async def generate_eob_batch(claims: list[dict]) -> None:
    """
    After a nightly claims run, generate EOB PDFs for all claimants concurrently.
    Each claim gets a document_url stored in the claims system.
    """
    async with httpx.AsyncClient() as client:
        tasks = [generate_eob_for_claim(client, claim) for claim in claims]
        results = await asyncio.gather(*tasks)

    for claim_id, document_url in results:
        db.claims.update(claim_id, {"eob_pdf_url": document_url})

# Called from your nightly claims processing job
if __name__ == "__main__":
    todays_claims = db.claims.find_by_processed_date(today_date_string)
    asyncio.run(generate_eob_batch(todays_claims))

Compliance formatting

Use @page { size: Letter; margin: 1in } in your policy document CSS. Most US carriers and state regulators expect Letter-sized output with one-inch margins. For page numbering, CSS counter(page) and counter(pages) in the @page margin box produce “Page 1 of 12”-style footers that satisfy NAIC model filing requirements for policy form pagination.

Document retention varies by state but typically runs five to seven years for commercial lines. Use the store: true flag on Growth or higher plans, which provides a 365-day storage window. For longer retention, write the document_url to your own document management system or claims platform on each generation and archive it there. PDFPipe storage is for retrieval convenience, not the system of record.

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 →