Guide Mar 26, 2026

PDF Invoice API: Generate Invoices from HTML with Stripe & Resend

Generate PDF invoices from HTML templates triggered by Stripe webhooks. Send via email with Resend. No server-side PDF libraries required.

Building a SaaS platform, marketplace, or subscription service means one thing: you need to generate invoices.

The typical approach is painful:

You need something simple: take HTML (your invoice template), convert it to PDF, email it to the customer, and store it for compliance — all triggered by a Stripe webhook. PageBolt does exactly this. One HTTPS request. No dependencies. Works on Vercel, Lambda, and traditional servers.

Why Developers Building Billing Systems Need This

Your SaaS needs PDF invoices for:

Solution: Stripe Webhook → PageBolt → Resend

When Stripe charges a customer, your backend renders an HTML invoice, sends it to PageBolt, gets back a PDF, and emails it via Resend.

Step 1: Create Invoice HTML Template

Your template is a Handlebars-style template stored in your database or file system:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      color: #111827;
      background: #fff;
      padding: 48px;
      max-width: 720px;
      margin: 0 auto;
    }
    .header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      margin-bottom: 48px;
      padding-bottom: 24px;
      border-bottom: 2px solid #f3f4f6;
    }
    .logo { font-size: 22px; font-weight: 800; color: #111827; }
    .invoice-meta { text-align: right; }
    .invoice-meta .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #9ca3af; }
    .invoice-meta .value { font-size: 14px; font-weight: 600; color: #374151; margin-top: 2px; }
    .invoice-number { font-size: 28px; font-weight: 800; color: #111827; margin-bottom: 4px; }
    .section-label {
      font-size: 11px;
      text-transform: uppercase;
      letter-spacing: 0.08em;
      color: #9ca3af;
      margin-bottom: 6px;
    }
    .bill-to { margin-bottom: 40px; }
    .bill-to .name { font-size: 16px; font-weight: 600; color: #111827; }
    .bill-to .email { font-size: 14px; color: #6b7280; margin-top: 2px; }
    table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
    thead th {
      font-size: 11px;
      text-transform: uppercase;
      letter-spacing: 0.06em;
      color: #9ca3af;
      padding: 8px 12px;
      border-bottom: 1px solid #e5e7eb;
      text-align: left;
    }
    thead th:last-child { text-align: right; }
    tbody td {
      padding: 14px 12px;
      border-bottom: 1px solid #f3f4f6;
      font-size: 14px;
      color: #374151;
    }
    tbody td:last-child { text-align: right; font-weight: 500; }
    .totals { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
    .total-row { display: flex; gap: 48px; font-size: 14px; color: #6b7280; }
    .total-row.grand { font-size: 18px; font-weight: 800; color: #111827; margin-top: 8px; padding-top: 12px; border-top: 2px solid #111827; }
    .footer { margin-top: 48px; padding-top: 24px; border-top: 1px solid #f3f4f6; font-size: 12px; color: #9ca3af; text-align: center; }
    .status-badge {
      display: inline-block;
      background: #dcfce7;
      color: #16a34a;
      font-size: 11px;
      font-weight: 700;
      padding: 3px 10px;
      border-radius: 999px;
      text-transform: uppercase;
      letter-spacing: 0.06em;
    }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="logo">{{companyName}}</div>
    </div>
    <div class="invoice-meta">
      <div class="invoice-number">{{invoiceNumber}}</div>
      <div class="label">Date issued</div>
      <div class="value">{{invoiceDate}}</div>
      <div style="margin-top:8px"><span class="status-badge">Paid</span></div>
    </div>
  </div>

  <div class="bill-to">
    <div class="section-label">Bill to</div>
    <div class="name">{{customerName}}</div>
    <div class="email">{{customerEmail}}</div>
  </div>

  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th>Qty</th>
        <th>Unit Price</th>
        <th>Amount</th>
      </tr>
    </thead>
    <tbody>
      {{#each lineItems}}
      <tr>
        <td>{{description}}</td>
        <td>{{quantity}}</td>
        <td>{{unitPrice}}</td>
        <td>{{amount}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>

  <div class="totals">
    {{#if taxAmount}}
    <div class="total-row"><span>Subtotal</span><span>{{currency}} {{subtotal}}</span></div>
    <div class="total-row"><span>Tax ({{taxRate}}%)</span><span>{{currency}} {{taxAmount}}</span></div>
    {{/if}}
    <div class="total-row grand"><span>Total</span><span>{{currency}} {{total}}</span></div>
  </div>

  <div class="footer">
    Thank you for your business. Questions? Contact us at {{supportEmail}}
  </div>
</body>
</html>

Step 2: Create the Invoice Generator Service

Create src/services/invoiceGenerator.ts:

import Handlebars from 'handlebars';
import fs from 'fs/promises';
import path from 'path';

interface LineItem {
  description: string;
  quantity: number;
  unitPrice: string;
  amount: string;
}

interface InvoiceData {
  companyName: string;
  invoiceNumber: string;
  invoiceDate: string;
  customerName: string;
  customerEmail: string;
  lineItems: LineItem[];
  subtotal: string;
  taxRate?: number;
  taxAmount?: string;
  total: string;
  currency: string;
  supportEmail: string;
}

// Load and compile template once at startup
let compiledTemplate: HandlebarsTemplateDelegate | null = null;

async function getTemplate(): Promise<HandlebarsTemplateDelegate> {
  if (compiledTemplate) return compiledTemplate;
  const templatePath = path.join(process.cwd(), 'templates', 'invoice.html');
  const source = await fs.readFile(templatePath, 'utf-8');
  compiledTemplate = Handlebars.compile(source);
  return compiledTemplate;
}

export async function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
  const template = await getTemplate();
  const html = template(data);

  const response = await fetch('https://pagebolt.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      options: {
        format: 'A4',
        margin: { top: '0', right: '0', bottom: '0', left: '0' },
        printBackground: true,
      },
    }),
  });

  if (!response.ok) {
    throw new Error(`PageBolt PDF generation failed: ${response.status}`);
  }

  return Buffer.from(await response.arrayBuffer());
}

Step 3: Stripe Webhook Handler

Create src/webhooks/stripe.ts:

import Stripe from 'stripe';
import { Resend } from 'resend';
import { generateInvoicePdf } from '../services/invoiceGenerator.js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const resend = new Resend(process.env.RESEND_API_KEY!);

export async function handleStripeWebhook(
  rawBody: Buffer,
  signature: string
): Promise<void> {
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    throw new Error(`Webhook signature verification failed: ${err}`);
  }

  switch (event.type) {
    case 'invoice.payment_succeeded':
      await handleInvoicePaid(event.data.object as Stripe.Invoice);
      break;
    case 'payment_intent.succeeded':
      await handleOneTimePayment(event.data.object as Stripe.PaymentIntent);
      break;
  }
}

async function handleInvoicePaid(stripeInvoice: Stripe.Invoice): Promise<void> {
  const customer = await stripe.customers.retrieve(
    stripeInvoice.customer as string
  ) as Stripe.Customer;

  const lineItems = stripeInvoice.lines.data.map((line) => ({
    description: line.description || 'Service',
    quantity: line.quantity || 1,
    unitPrice: formatCurrency(line.unit_amount_excluding_tax || line.amount, stripeInvoice.currency),
    amount: formatCurrency(line.amount, stripeInvoice.currency),
  }));

  const subtotal = stripeInvoice.subtotal;
  const tax = stripeInvoice.tax || 0;
  const total = stripeInvoice.amount_paid;

  const invoiceData = {
    companyName: 'Your Company',
    invoiceNumber: stripeInvoice.number || `INV-${Date.now()}`,
    invoiceDate: new Date(stripeInvoice.created * 1000).toLocaleDateString('en-US', {
      year: 'numeric', month: 'long', day: 'numeric',
    }),
    customerName: customer.name || customer.email || 'Customer',
    customerEmail: customer.email!,
    lineItems,
    subtotal: formatCurrency(subtotal, stripeInvoice.currency),
    taxRate: tax > 0 ? ((tax / subtotal) * 100).toFixed(0) : undefined,
    taxAmount: tax > 0 ? formatCurrency(tax, stripeInvoice.currency) : undefined,
    total: formatCurrency(total, stripeInvoice.currency),
    currency: stripeInvoice.currency.toUpperCase(),
    supportEmail: 'support@yourcompany.com',
  };

  const pdfBuffer = await generateInvoicePdf(invoiceData);

  await resend.emails.send({
    from: 'billing@yourcompany.com',
    to: customer.email!,
    subject: `Invoice ${invoiceData.invoiceNumber} — Payment confirmed`,
    html: `<p>Hi ${invoiceData.customerName},</p>
           <p>Thank you for your payment. Please find your invoice attached.</p>
           <p>— Your Company Team</p>`,
    attachments: [
      {
        filename: `${invoiceData.invoiceNumber}.pdf`,
        content: pdfBuffer.toString('base64'),
      },
    ],
  });

  console.log(`Invoice ${invoiceData.invoiceNumber} sent to ${customer.email}`);
}

async function handleOneTimePayment(intent: Stripe.PaymentIntent): Promise<void> {
  const charges = await stripe.charges.list({ payment_intent: intent.id, limit: 1 });
  const charge = charges.data[0];
  if (!charge?.billing_details?.email) return;

  const invoiceData = {
    companyName: 'Your Company',
    invoiceNumber: `RCP-${intent.id.slice(-8).toUpperCase()}`,
    invoiceDate: new Date().toLocaleDateString('en-US', {
      year: 'numeric', month: 'long', day: 'numeric',
    }),
    customerName: charge.billing_details.name || charge.billing_details.email,
    customerEmail: charge.billing_details.email,
    lineItems: [{
      description: intent.description || 'One-time payment',
      quantity: 1,
      unitPrice: formatCurrency(intent.amount, intent.currency),
      amount: formatCurrency(intent.amount, intent.currency),
    }],
    subtotal: formatCurrency(intent.amount, intent.currency),
    total: formatCurrency(intent.amount, intent.currency),
    currency: intent.currency.toUpperCase(),
    supportEmail: 'support@yourcompany.com',
  };

  const pdfBuffer = await generateInvoicePdf(invoiceData);

  await resend.emails.send({
    from: 'billing@yourcompany.com',
    to: charge.billing_details.email,
    subject: `Receipt ${invoiceData.invoiceNumber}`,
    html: `<p>Hi ${invoiceData.customerName},</p><p>Your receipt is attached.</p>`,
    attachments: [{
      filename: `${invoiceData.invoiceNumber}.pdf`,
      content: pdfBuffer.toString('base64'),
    }],
  });
}

function formatCurrency(amount: number | null, currency: string): string {
  const value = (amount || 0) / 100;
  return new Intl.NumberFormat('en-US', {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(value);
}

Step 4: Register Webhook in Express

import express from 'express';
import { handleStripeWebhook } from './webhooks/stripe.js';

const app = express();

// IMPORTANT: Use raw body parser for Stripe webhooks only
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'] as string;
    try {
      await handleStripeWebhook(req.body, signature);
      res.json({ received: true });
    } catch (err) {
      console.error('Webhook error:', err);
      res.status(400).send(`Webhook Error: ${err}`);
    }
  }
);

// Use JSON parser for all other routes
app.use(express.json());

app.listen(3000, () => console.log('Server running on port 3000'));

Step 5: Add Environment Variables

# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
PAGEBOLT_API_KEY=pb_live_...
RESEND_API_KEY=re_...

Recurring Invoices on a Schedule

For subscription billing, generate invoices monthly:

import cron from 'node-cron';
import { db } from './database.js';
import { generateInvoicePdf } from './services/invoiceGenerator.js';
import { resend } from './services/email.js';

// Run on the 1st of every month at 9:00 AM UTC
cron.schedule('0 9 1 * *', async () => {
  console.log('Running monthly invoice generation...');

  const activeSubscriptions = db
    .prepare(`SELECT * FROM subscriptions WHERE status = 'active'`)
    .all();

  for (const subscription of activeSubscriptions) {
    try {
      const invoiceNumber = `INV-${new Date().getFullYear()}${
        String(new Date().getMonth() + 1).padStart(2, '0')
      }-${subscription.id}`;

      const invoiceData = {
        companyName: 'Your Company',
        invoiceNumber,
        invoiceDate: new Date().toLocaleDateString('en-US', {
          year: 'numeric', month: 'long', day: 'numeric',
        }),
        customerName: subscription.customer_name,
        customerEmail: subscription.customer_email,
        lineItems: [{
          description: `${subscription.plan_name} — Monthly subscription`,
          quantity: 1,
          unitPrice: `$${(subscription.amount_cents / 100).toFixed(2)}`,
          amount: `$${(subscription.amount_cents / 100).toFixed(2)}`,
        }],
        subtotal: `$${(subscription.amount_cents / 100).toFixed(2)}`,
        total: `$${(subscription.amount_cents / 100).toFixed(2)}`,
        currency: 'USD',
        supportEmail: 'support@yourcompany.com',
      };

      const pdfBuffer = await generateInvoicePdf(invoiceData);

      await resend.emails.send({
        from: 'billing@yourcompany.com',
        to: subscription.customer_email,
        subject: `Your ${new Date().toLocaleString('default', { month: 'long' })} invoice`,
        html: `<p>Hi ${subscription.customer_name},</p>
               <p>Your monthly invoice is attached.</p>`,
        attachments: [{
          filename: `${invoiceNumber}.pdf`,
          content: pdfBuffer.toString('base64'),
        }],
      });

      // Store invoice record
      db.prepare(`
        INSERT INTO invoices (number, subscription_id, amount_cents, sent_at)
        VALUES (?, ?, ?, datetime('now'))
      `).run(invoiceNumber, subscription.id, subscription.amount_cents);

      console.log(`Sent invoice ${invoiceNumber} to ${subscription.customer_email}`);
    } catch (err) {
      console.error(`Failed to send invoice for subscription ${subscription.id}:`, err);
    }
  }
});

Comparison: PageBolt vs Alternatives

Feature PageBolt wkhtmltopdf Puppeteer Invoice API
Setup time 2 minutes 30+ minutes 45+ minutes N/A
Server bloat None System deps +200MB None
CSS support Full HTML5/CSS3 Limited Full Full
Serverless Yes No No Yes
Cost per invoice $0.006 (Starter) Free (time cost) Free (infra cost) $0.50–$2.00

Getting Started

  1. Sign up at pagebolt.dev (100 free requests/month, no credit card)
  2. Get your API key from the dashboard
  3. Integrate the Stripe webhook handler above
  4. Create your HTML invoice template
  5. Test with Stripe test mode — make a test charge, verify invoice arrives

Start generating PDF invoices in minutes

One API call converts your HTML invoice template to a pixel-perfect PDF. Works on Vercel, Lambda, and traditional servers. 100 free requests/month — no credit card required.

Get your free API key