How to auto-generate PDF invoices on Stripe payment

Published: (February 26, 2026 at 03:17 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

Stripe generates receipts automatically, but they’re Stripe‑branded and can’t be customized. If you need invoices that match your brand, include custom line items, or are required for VAT compliance in certain countries, you’ll have to generate them yourself.

Full pipeline: Stripe webhook → render HTML template → capture as PDF → email to customer.

Setup

You’ll need three things:

  1. A Stripe webhook that fires on payment_intent.succeeded or invoice.payment_succeeded.
  2. An HTML invoice template (see below).
  3. One PageBolt call to capture the rendered HTML as a PDF.

Install the required npm packages:

npm install stripe @sendgrid/mail express

Webhook handler

import Stripe from "stripe";
import sgMail from "@sendgrid/mail";
import express from "express";
import { renderInvoiceHtml } from "./templates/invoice.js";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
sgMail.setApiKey(process.env.SENDGRID_API_KEY);

const app = express();

// Use raw body for Stripe signature verification
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.headers["stripe-signature"];

    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    if (event.type === "invoice.payment_succeeded") {
      await handleInvoicePaid(event.data.object);
    }

    res.json({ received: true });
  }
);

Generate and send the PDF

async function handleInvoicePaid(stripeInvoice) {
  // Fetch full customer details
  const customer = await stripe.customers.retrieve(stripeInvoice.customer);

  // Build your invoice data
  const invoiceData = {
    number: stripeInvoice.number,
    date: new Date(stripeInvoice.created * 1000).toLocaleDateString(),
    customerName: customer.name,
    customerEmail: customer.email,
    lines: stripeInvoice.lines.data.map((line) => ({
      description: line.description,
      amount: (line.amount / 100).toFixed(2),
      currency: stripeInvoice.currency.toUpperCase(),
    })),
    total: (stripeInvoice.amount_paid / 100).toFixed(2),
    currency: stripeInvoice.currency.toUpperCase(),
  };

  // Render HTML template
  const html = renderInvoiceHtml(invoiceData);

  // Capture as PDF via PageBolt
  const pdfRes = 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 }),
  });

  const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());

  // Email to customer
  await sgMail.send({
    to: customer.email,
    from: "billing@yourapp.com",
    subject: `Invoice ${invoiceData.number} — Payment confirmed`,
    text: `Hi ${customer.name}, your payment was successful. Invoice attached.`,
    attachments: [
      {
        content: pdfBuffer.toString("base64"),
        filename: `invoice-${invoiceData.number}.pdf`,
        type: "application/pdf",
        disposition: "attachment",
      },
    ],
  });

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

HTML invoice template

// templates/invoice.js
export function renderInvoiceHtml(invoice) {
  const lineItems = invoice.lines
    .map(
      (line) => `
        ${line.description}
        ${line.currency} ${line.amount}
      `
    )
    .join("");

  return `
    <style>
      body { font-family: -apple-system, sans-serif; color: #111; max-width: 680px; margin: 40px auto; padding: 0 20px; }
      .header { display: flex; justify-content: space-between; margin-bottom: 40px; }
      .logo { font-size: 24px; font-weight: 700; }
      table { width: 100%; border-collapse: collapse; margin: 24px 0; }
      th { text-align: left; border-bottom: 2px solid #111; padding: 8px 0; }
      td { padding: 10px 0; border-bottom: 1px solid #eee; }
      .total { font-size: 18px; font-weight: 700; text-align: right; margin-top: 16px; }
      .meta { color: #666; font-size: 14px; }
    </style>

    <div class="header">
      <div class="logo">YourApp</div>
      <div>
        <div>Invoice ${invoice.number}</div>
        <div>${invoice.date}</div>
      </div>
    </div>

    <div>
      <strong>Bill to:</strong><br/>
      ${invoice.customerName}<br/>
      ${invoice.customerEmail}
    </div>

    <table>
      <thead>
        <tr>
          <th>Description</th>
          <th>Amount</th>
        </tr>
      </thead>
      <tbody>
        ${lineItems}
      </tbody>
    </table>

    <div class="total">Total: ${invoice.currency} ${invoice.total}</div>
  `;
}

Also handle one‑time payments

For payment_intent.succeeded (non‑subscription payments) you can generate a simple receipt:

if (event.type === "payment_intent.succeeded") {
  const paymentIntent = event.data.object;

  // Retrieve the charge to get receipt details
  const charges = await stripe.charges.list({
    payment_intent: paymentIntent.id,
    limit: 1,
  });

  const charge = charges.data[0];
  if (!charge?.billing_details?.email) return;

  const html = renderSimpleReceiptHtml({
    amount: (paymentIntent.amount / 100).toFixed(2),
    currency: paymentIntent.currency.toUpperCase(),
    // add any other fields you need
  });

  // …capture PDF and email as shown above
}

(Implement renderSimpleReceiptHtml similarly to the invoice template.)

Generate and email a PDF invoice (one‑time payments)

// Example continuation for one‑time payments
const html = renderSimpleReceiptHtml({
  amount: (paymentIntent.amount / 100).toFixed(2),
  currency: paymentIntent.currency.toUpperCase(),
  email: charge.billing_details.email,
  date: new Date().toLocaleDateString(),
  description: paymentIntent.description || "Payment",
});

const pdfRes = 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 }),
});

const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());

// Email as shown in the invoice example
await sgMail.send({
  to: charge.billing_details.email,
  from: "billing@yourapp.com",
  subject: "Payment receipt",
  text: "Your payment was successful. Receipt attached.",
  attachments: [
    {
      content: pdfBuffer.toString("base64"),
      filename: "receipt.pdf",
      type: "application/pdf",
      disposition: "attachment",
    },
  ],
});

Store PDFs in S3 (optional)

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "us-east-1" });

async function storePdf(pdfBuffer, invoiceNumber) {
  await s3.send(
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: `invoices/${invoiceNumber}.pdf`,
      Body: pdfBuffer,
      ContentType: "application/pdf",
    })
  );

  return `https://${process.env.S3_BUCKET}.s3.amazonaws.com/invoices/${invoiceNumber}.pdf`;
}

Test locally with Stripe CLI

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/webhooks/stripe

# Trigger a test payment
stripe trigger invoice.payment_succeeded

The PDF will be generated and emailed within seconds of the test event firing.

Try it free — 100 requests/month, no credit card required. → Get started in 2 minutes

0 views
Back to Blog

Related posts

Read more »