Automating Invoice Generation with HTML-to-PDF

Every successful payment creates a document problem. Customers want a receipt. Accountants want a proper invoice. Compliance teams want both, stored forever. If you generate those PDFs by hand, or rely on a static library that can't render modern CSS, you end up with brittle code and hours of edge-case debugging.
This guide builds a Stripe webhook pipeline that converts each successful payment into a professional PDF invoice and delivers it automatically. The implementation uses the Rendex html to pdf invoice api and runs in any Node.js app or Cloudflare Worker. No Chromium binary to install. No wkhtmltopdf process to maintain.
Prerequisites
- A Stripe account with webhook access configured
- A Rendex API key. The free tier covers 100 calls/month. Get one at rendex.dev/login.
- Node.js 18+ or Bun
- An object storage bucket (S3, R2, etc.) for PDF storage
How the Pipeline Works
Four steps, one payment event:
- Stripe fires a
payment_intent.succeededevent to your webhook endpoint - Your handler extracts customer and line-item data from the payload
- You build a self-contained HTML invoice template with that data
- Rendex converts the HTML to a PDF at the
/v1/screenshotendpoint and returns the binary document - You store the PDF and send a download link by email
The rendering runs on Cloudflare's global edge. Your server never touches a browser process.
Step 1: Handle the Stripe Webhook
Register a POST endpoint with Stripe. Verify the signature on every request before touching the payload.
// npm install stripe express
import express from "express"
import Stripe from "stripe"
import { generateAndDeliverInvoice } from "./invoice"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
})
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
const app = express()
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"]!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
} catch {
return res.status(400).send("Webhook signature verification failed")
}
// Acknowledge immediately — PDF generation goes to a background queue
res.json({ received: true })
if (event.type === "payment_intent.succeeded") {
const intent = event.data.object as Stripe.PaymentIntent
await generateAndDeliverInvoice(intent).catch((err) => {
console.error("Invoice generation failed", { id: intent.id, err })
})
}
}
)
app.listen(3000)The express.raw()middleware is required. Stripe's signature verification needs the raw request body, not a parsed object. Acknowledge the webhook before awaiting the PDF job so Stripe doesn't retry on a slow render.
Step 2: Build the Invoice Template
Construct a self-contained HTML string. Use inline styles: external stylesheets won't load when you pass raw HTML to the renderer.
export interface InvoiceData {
invoiceNumber: string
customerName: string
customerEmail: string
amount: number // in cents
currency: string // "usd", "eur", etc.
items: Array<{
description: string
quantity: number
unitPrice: number // in cents
}>
issuedAt: string
}
export function buildInvoiceHtml(data: InvoiceData): string {
const total = (data.amount / 100).toFixed(2)
const rows = data.items
.map(
(item) => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #e5e7eb">
${item.description}
</td>
<td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">
${item.quantity}
</td>
<td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">
${(item.unitPrice / 100).toFixed(2)} ${data.currency.toUpperCase()}
</td>
</tr>`
)
.join("")
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px; color: #111827; margin: 0; padding: 40px;
}
h1 { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
th {
text-align: left; padding: 8px 0;
border-bottom: 2px solid #111827; font-weight: 600;
}
.total { font-size: 18px; font-weight: 700; margin-top: 16px; text-align: right; }
</style>
</head>
<body>
<h1>Invoice #${data.invoiceNumber}</h1>
<p style="color:#6b7280">Issued: ${data.issuedAt}</p>
<p>
<strong>Bill to:</strong>
${data.customerName} (${data.customerEmail})
</p>
<table>
<thead>
<tr>
<th>Description</th>
<th style="text-align:right">Qty</th>
<th style="text-align:right">Amount</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p class="total">Total: ${total} ${data.currency.toUpperCase()}</p>
</body>
</html>`
}Images should use base64 data URIs or absolute HTTPS URLs so the renderer can load them without access to your local filesystem.
Step 3: Convert HTML to PDF
Pass the HTML string to Rendex. The SDK posts to the /v1/screenshot endpoint with format: "pdf" and returns binary bytes.
// npm install @copperline/rendex
import { Rendex } from "@copperline/rendex"
const rendex = new Rendex(process.env.RENDEX_API_KEY!)
// Get your API key at https://rendex.dev/login
export async function generateInvoicePdf(html: string): Promise<Uint8Array> {
const { image } = await rendex.screenshot({
html,
format: "pdf",
pdfFormat: "Letter", // A4, Letter, Legal, Tabloid, A3
pdfPrintBackground: true, // render background colors and images
pdfMargin: {
top: "20mm",
right: "20mm",
bottom: "20mm",
left: "20mm",
},
})
return image
}The same call over raw HTTP, if you prefer no SDK dependency:
curl -X POST https://api.rendex.dev/v1/screenshot \
-H "Authorization: Bearer rdx_your_key" \
-H "Content-Type: application/json" \
-d '{
"html": "<!DOCTYPE html>...",
"format": "pdf",
"pdfFormat": "Letter",
"pdfPrintBackground": true,
"pdfMargin": {
"top": "20mm", "right": "20mm",
"bottom": "20mm", "left": "20mm"
}
}' --output invoice.pdfOther PDF options: pdfLandscape: true for wide tables,pdfScale (0.1 to 2) to shrink dense content onto fewer pages, and pdfFormat: "A4" for international invoices. Full parameter list at the API reference.
Step 4: Store and Deliver
Upload the PDF bytes to object storage and email a signed download link.
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
const s3 = new S3Client({ region: "us-east-1" })
const BUCKET = process.env.INVOICE_BUCKET!
export async function storeAndDeliver(
pdf: Uint8Array,
invoiceNumber: string,
customerEmail: string
): Promise<void> {
const key = `invoices/${invoiceNumber}.pdf`
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: pdf,
ContentType: "application/pdf",
ContentDisposition: `attachment; filename="${invoiceNumber}.pdf"`,
})
)
const downloadUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
{ expiresIn: 604800 } // 7 days
)
await sendInvoiceEmail(customerEmail, invoiceNumber, downloadUrl)
// Implement sendInvoiceEmail with Resend, SendGrid, or your SMTP provider
}Wiring It Together
The generateAndDeliverInvoice function ties all three steps together:
import type Stripe from "stripe"
import { buildInvoiceHtml } from "./invoice-template"
import { generateInvoicePdf } from "./generate-pdf"
import { storeAndDeliver } from "./deliver-invoice"
// Track processed IDs to handle Stripe retry events
const processedIds = new Set<string>()
export async function generateAndDeliverInvoice(
intent: Stripe.PaymentIntent
): Promise<void> {
if (processedIds.has(intent.id)) return // idempotency guard
processedIds.add(intent.id)
const invoiceData = {
invoiceNumber: intent.id.slice(-8).toUpperCase(),
customerName: intent.metadata.customerName ?? "Customer",
customerEmail: intent.metadata.customerEmail ?? "",
amount: intent.amount,
currency: intent.currency,
items: [
{
description: intent.metadata.description ?? "Service",
quantity: 1,
unitPrice: intent.amount,
},
],
issuedAt: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
}
const html = buildInvoiceHtml(invoiceData)
const pdf = await generateInvoicePdf(html)
await storeAndDeliver(pdf, invoiceData.invoiceNumber, invoiceData.customerEmail)
}The in-memory Set handles duplicate events within a single process lifetime. For multi-instance deployments, move the processed-ID check to Redis or your database.
Production Considerations
- Idempotency at scale: Store processed
payment_intent.idvalues in a database with a unique constraint. The in-memory set above works for a single-process deployment but not for horizontally scaled services. - Background processing:The example awaits PDF generation inside the webhook handler. For production, push the intent ID onto a queue and acknowledge Stripe immediately. Stripe retries any webhook that doesn't respond within 30 seconds.
- Error handling: The Rendex SDK throws a typed
RendexApiErroron failures. Catch it, log thestatusanderrorCode, and retry with exponential backoff. Most transient failures resolve on the second attempt. - Template versioning: Store the template version with each invoice record. When your invoice design changes, historical regenerations still use the original layout.
- Customer data in metadata: The example reads
intent.metadata.customerEmail. Set this when you create the PaymentIntent, or fetch it from your user table using the customer ID attached to the intent.
Batch Invoice Generation
If you need to regenerate invoices in bulk, such as at month-end or after a data migration, the invoice rendering use case covers the batch endpoint, which accepts up to 500 HTML payloads per request on Pro and Enterprise plans.
For a comparison of tools and libraries that generate PDFs from HTML, see the Python PDF generation guide. The approach is the same across languages: build clean HTML, pass it to a renderer, get back a file.
Next Steps
Test your invoice template before writing any Stripe code. Paste the HTML into the free URL-to-PDF tool and check the output in seconds. Iterate on layout and margins until it looks right.
When you're ready to integrate, get a free API key (100 calls/month, no credit card required). The Starter plan at $69/month covers most small business invoice volumes.
Not sure whether to use an API or self-host? See Best HTML-to-PDF Tools in 2026 for a side-by-side comparison of Rendex, DocRaptor, wkhtmltopdf, and Playwright.