PDFPipe

Use case

SaaS Invoice PDF

Generate branded billing documents for every customer automatically when a payment clears. Stripe, Paddle, or any billing provider can trigger the flow. The result is a stored PDF your customer can download from their portal for years.

Invoice HTML template

A Mustache template with your brand colors, line item table, and legal footer. Render it server-side with the customer's billing data, then POST the HTML to the PDF API. No PDF library to install, no font paths to configure.

HTML template (Mustache)
<!-- templates/invoice.html -->
<!DOCTYPE html>
<html>
<head>
<style>
  body { font-family: 'Inter', sans-serif; color: #1a1a1a; margin: 0; padding: 48px; }
  .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 48px; }
  .logo { font-size: 24px; font-weight: 700; color: #d23a1d; }
  .invoice-meta { text-align: right; font-size: 13px; color: #666; }
  .invoice-number { font-size: 20px; font-weight: 600; color: #1a1a1a; }
  .bill-to { margin-bottom: 40px; }
  .bill-to h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #999; margin-bottom: 8px; }
  table { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
  th { text-align: left; padding: 10px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #999; border-bottom: 1px solid #e5e5e5; }
  td { padding: 14px 0; font-size: 14px; border-bottom: 1px solid #f0f0f0; }
  .amount { text-align: right; }
  .totals { width: 240px; margin-left: auto; }
  .totals tr td { border: none; padding: 6px 0; }
  .total-row td { font-weight: 700; font-size: 16px; padding-top: 12px; border-top: 2px solid #1a1a1a; }
  .footer { margin-top: 64px; font-size: 12px; color: #999; border-top: 1px solid #e5e5e5; padding-top: 24px; }
  @page { margin: 0; size: A4; }
</style>
</head>
<body>
  <div class="header">
    <div class="logo">{{companyName}}</div>
    <div class="invoice-meta">
      <div class="invoice-number">Invoice #{{invoiceNumber}}</div>
      <div>Issued: {{issueDate}}</div>
      <div>Due: {{dueDate}}</div>
    </div>
  </div>

  <div class="bill-to">
    <h3>Bill to</h3>
    <p>{{customerName}}<br>{{customerEmail}}<br>{{customerAddress}}</p>
  </div>

  <table>
    <thead>
      <tr><th>Description</th><th>Qty</th><th>Rate</th><th class="amount">Amount</th></tr>
    </thead>
    <tbody>
      {{#lineItems}}
      <tr>
        <td>{{description}}</td>
        <td>{{quantity}}</td>
        <td>{{rate}}</td>
        <td class="amount">{{amount}}</td>
      </tr>
      {{/lineItems}}
    </tbody>
  </table>

  <table class="totals">
    <tr><td>Subtotal</td><td class="amount">{{subtotal}}</td></tr>
    <tr><td>Tax ({{taxRate}}%)</td><td class="amount">{{tax}}</td></tr>
    <tr class="total-row"><td>Total</td><td class="amount">{{total}}</td></tr>
  </table>

  <div class="footer">
    Payment due within {{paymentTerms}} days. {{paymentInstructions}}<br>
    Questions? Contact {{supportEmail}}
  </div>
</body>
</html>

Stripe webhook handler

Listen for invoice.payment_succeeded, fill the template, post to the API, and store the returned URL. The whole flow runs inside the webhook handler well within Stripe's 30-second timeout.

TypeScript
// webhook/stripe.ts — invoice PDF on subscription renewal
import Stripe from "stripe";
import Mustache from "mustache";
import { readFileSync } from "fs";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const template = readFileSync("./templates/invoice.html", "utf-8");

export async function handleStripeWebhook(req: Request): Promise<Response> {
  const sig = req.headers.get("stripe-signature")!;
  const rawBody = await req.text();
  const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!);

  if (event.type === "invoice.payment_succeeded") {
    const invoice = event.data.object as Stripe.Invoice;
    const customer = await stripe.customers.retrieve(invoice.customer as string) as Stripe.Customer;

    const html = Mustache.render(template, {
      companyName: "Acme SaaS",
      invoiceNumber: invoice.number,
      issueDate: new Date(invoice.created * 1000).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
      dueDate: new Date((invoice.due_date ?? invoice.created) * 1000).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
      customerName: customer.name,
      customerEmail: customer.email,
      customerAddress: formatAddress(customer.address),
      lineItems: invoice.lines.data.map((line) => ({
        description: line.description,
        quantity: line.quantity ?? 1,
        rate: formatAmount(line.unit_amount_excluding_tax ?? 0, invoice.currency),
        amount: formatAmount(line.amount_excluding_tax ?? 0, invoice.currency),
      })),
      subtotal: formatAmount(invoice.subtotal_excluding_tax ?? invoice.subtotal, invoice.currency),
      taxRate: "20",
      tax: formatAmount(invoice.tax ?? 0, invoice.currency),
      total: formatAmount(invoice.amount_paid, invoice.currency),
      paymentTerms: "30",
      paymentInstructions: "Payment via card on file.",
      supportEmail: "[email protected]",
    });

    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: `invoice-${invoice.number}.pdf`,
        store: true,
        options: { format: "A4" },
      }),
    });

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

    // Save to your DB for the customer portal
    await db.invoices.upsert({
      stripeInvoiceId: invoice.id,
      customerId: invoice.customer as string,
      pdfId: document_id,
      pdfUrl: document_url,
      expiresAt: new Date(document_expires),
      amount: invoice.amount_paid,
      currency: invoice.currency,
    });

    // Email the customer
    await sendBillingEmail(customer.email!, {
      invoiceNumber: invoice.number!,
      amount: formatAmount(invoice.amount_paid, invoice.currency),
      downloadUrl: document_url,
    });
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 });
}

function formatAmount(cents: number, currency: string): string {
  return new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
}

Paddle Billing

The same pattern works with Paddle's transaction.completed event. Swap the template data, the rest is identical.

Node.js
// Paddle Billing webhook (similar pattern)
app.post("/webhooks/paddle", express.raw({ type: "application/json" }), async (req, res) => {
  const event = paddle.webhooks.unmarshal(req.rawBody, process.env.PADDLE_WEBHOOK_SECRET!);

  if (event.eventType === EventName.TransactionCompleted) {
    const txn = event.data as Transaction;

    const html = Mustache.render(template, {
      invoiceNumber: txn.id,
      customerName: txn.customData?.customerName,
      lineItems: txn.items.map((item) => ({
        description: item.price?.description,
        quantity: item.quantity,
        amount: formatMoney(item.totals?.total, txn.currencyCode),
      })),
      total: formatMoney(txn.details?.totals?.total, txn.currencyCode),
    });

    const { document_url } = 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, store: true, options: { format: "A4" } }),
    }).then((r) => r.json());

    await db.receipts.create({ transactionId: txn.id, pdfUrl: document_url });
  }

  res.sendStatus(200);
});

Customer portal endpoint

Expose the stored document URLs through an API route in your app. The PDF lives at a stable URL for the retention period of the plan (30 days on Starter, 1 year on Growth, 2 years on Scale and Business).

Next.js Route Handler
// GET /api/invoices — customer portal endpoint
// Returns stored invoice PDFs for the logged-in user
export async function GET(req: Request) {
  const session = await getServerSession(authOptions);
  if (!session) return new Response("Unauthorized", { status: 401 });

  const invoices = await db.invoices.findByCustomerId(session.user.stripeId, {
    orderBy: { createdAt: "desc" },
    take: 50,
  });

  return Response.json({
    invoices: invoices.map((inv) => ({
      id: inv.id,
      number: inv.stripeInvoiceId,
      amount: inv.amount,
      currency: inv.currency,
      date: inv.createdAt,
      downloadUrl: inv.pdfUrl,
      expiresAt: inv.expiresAt,
    })),
  });
}

Plans

PlanInvoices/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 invoices per month? Contact us for enterprise pricing.

Try it free →