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.
<!-- 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.
// 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.
// 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).
// 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
| Plan | Invoices/mo | Storage | Price |
|---|---|---|---|
| Hobby | 500 | 1 day | Free |
| Starter | 3,000 | 30 days | $19/mo |
| Growth | 15,000 | 365 days | $49/mo |
| Scale | 50,000 | 2 years | $149/mo |
| Business | 100,000 | 2 years | $499/mo |
Need more than 100,000 invoices per month? Contact us for enterprise pricing.