Automating Invoice Generation with HTML-to-PDF

Rendex Team··7 min read
pdftutorialautomation
TypeScript code showing html to pdf invoice api: Rendex screenshot call with html and format:pdf parameters for automated invoice rendering

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:

  1. Stripe fires a payment_intent.succeeded event to your webhook endpoint
  2. Your handler extracts customer and line-item data from the payload
  3. You build a self-contained HTML invoice template with that data
  4. Rendex converts the HTML to a PDF at the /v1/screenshot endpoint and returns the binary document
  5. 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.

stripe-webhook.ts
// 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.

invoice-template.ts
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.

generate-pdf.ts
// 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:

generate-invoice.sh
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.pdf

Other 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.

deliver-invoice.ts
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:

invoice.ts
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.id values 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 RendexApiError on failures. Catch it, log the status and errorCode, 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.

Try Rendex Free

100 screenshots/month. No credit card required.

Get API Key