Every payment needs a receipt. Your customers expect one, your accountant wants one, and depending on your market, the law requires one. But if you have ever tried to generate a PDF receipt from a Stripe webhook, you know the drill: cobble together some HTML, spin up Puppeteer, pray it renders correctly in production, and wonder why your Lambda just timed out.

There is a simpler way. This guide walks through a clean setup: Stripe fires a webhook, your server makes one API call to generate a PDF, and you email the receipt. No headless browsers, no HTML-to-PDF libraries, no rendering infrastructure to maintain.

The problem: turning payment data into a PDF

Stripe gives you everything you need to know about a payment. The checkout.session.completed or invoice.payment_succeeded event contains the customer name, line items, amounts, tax, currency. What Stripe does not give you is a polished PDF receipt you can attach to a confirmation email.

So you are left to build it yourself. The usual approaches:

  • Puppeteer / Playwright: Spin up a headless browser, load some HTML, call page.pdf(). Works locally, breaks in production. Cold starts are slow, memory usage is high, and you need a Chromium binary on your server.
  • PDFKit / jsPDF: Build the PDF programmatically, pixel by pixel. Want a table? Do the math. Want a logo? Load the image buffer. Every layout change means rewriting code.
  • wkhtmltopdf: A classic, but aging. Font rendering is inconsistent, CSS support is limited, and deployment is its own project.
  • Hardcoded HTML strings: Template literals with inline styles, fed into whatever converter you found on npm. Fragile, ugly to maintain, and good luck updating the layout six months later.

All of these share the same fundamental issue: you are mixing document design with application logic. Your webhook handler should not care about CSS margins or font loading. It should send data somewhere and get a PDF back.

The approach: template + webhook + API call

Here is the architecture:

  1. Design a receipt template using HTML and Tailwind CSS, with Handlebars variables for dynamic data (customer name, line items, totals).
  2. Listen for Stripe webhooks in your application.
  3. Call a PDF API with the payment data as variables. Get back a PDF URL.
  4. Send the receipt via email, store it, or both.

The template lives separately from your code. When you need to update the receipt layout, you edit HTML. When the payment flow changes, you edit code. They never tangle.

The receipt template

Here is a receipt template using HTML, Tailwind CSS, and Handlebars syntax. This is what you would set up in your PDF generation tool as a reusable template:

<div class="max-w-2xl mx-auto p-8 font-sans">
  <div class="flex justify-between items-start mb-8">
    <div>
      <h1 class="text-2xl font-bold text-gray-900">Receipt</h1>
      <p class="text-sm text-gray-500 mt-1">#{{receipt_number}}</p>
    </div>
    <div class="text-right text-sm text-gray-600">
      <p>{{company.name}}</p>
      <p>{{company.address}}</p>
      <p>{{company.email}}</p>
    </div>
  </div>

  <div class="mb-8 p-4 bg-gray-50 rounded-lg">
    <p class="text-sm text-gray-500">Billed to</p>
    <p class="font-medium text-gray-900">{{customer.name}}</p>
    <p class="text-sm text-gray-600">{{customer.email}}</p>
    {{#if customer.address}}
      <p class="text-sm text-gray-600">{{customer.address}}</p>
    {{/if}}
  </div>

  <table class="w-full mb-8 text-sm">
    <thead>
      <tr class="border-b border-gray-200">
        <th class="text-left py-2 text-gray-500 font-medium">Description</th>
        <th class="text-right py-2 text-gray-500 font-medium">Qty</th>
        <th class="text-right py-2 text-gray-500 font-medium">Unit price</th>
        <th class="text-right py-2 text-gray-500 font-medium">Amount</th>
      </tr>
    </thead>
    <tbody>
      {{#each line_items}}
        <tr class="border-b border-gray-100">
          <td class="py-3 text-gray-900">{{this.description}}</td>
          <td class="py-3 text-right text-gray-600">{{this.quantity}}</td>
          <td class="py-3 text-right text-gray-600">{{this.unit_price}}</td>
          <td class="py-3 text-right text-gray-900 font-medium">{{this.amount}}</td>
        </tr>
      {{/each}}
    </tbody>
  </table>

  <div class="flex justify-end">
    <div class="w-64">
      {{#if subtotal}}
        <div class="flex justify-between py-1 text-sm text-gray-600">
          <span>Subtotal</span>
          <span>{{subtotal}}</span>
        </div>
      {{/if}}
      {{#if tax}}
        <div class="flex justify-between py-1 text-sm text-gray-600">
          <span>Tax</span>
          <span>{{tax}}</span>
        </div>
      {{/if}}
      <div class="flex justify-between py-2 text-base font-bold text-gray-900 border-t border-gray-300 mt-2">
        <span>Total</span>
        <span>{{total}}</span>
      </div>
    </div>
  </div>

  <div class="mt-8 pt-6 border-t border-gray-200 text-xs text-gray-400 text-center">
    <p>Paid on {{paid_date}} via {{payment_method}}</p>
    <p class="mt-1">Thank you for your purchase.</p>
  </div>
</div>

A few things to note:

  • Handlebars conditionals ({{#if}}) handle optional fields like tax or customer address. Not every receipt needs every field.
  • Loops ({{#each}}) iterate over line items. Whether the customer bought one item or twenty, the same template works.
  • Tailwind classes handle all the styling. No inline CSS, no external stylesheets to manage.

This template is created once in your PDF tool's dashboard and referenced by its document ID in API calls.

The Stripe webhook handler

Here is a complete Node.js webhook handler using Express. When Stripe sends a checkout.session.completed event, the handler extracts the payment data, generates a PDF receipt, and sends it by email.

import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Stripe needs the raw body for signature verification
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error("Webhook signature verification failed:", err.message);
      return res.status(400).send("Invalid signature");
    }

    if (event.type === "checkout.session.completed") {
      // Respond immediately so Stripe doesn't retry
      res.status(200).json({ received: true });

      // Generate the receipt asynchronously
      try {
        await handleCheckoutCompleted(event.data.object);
      } catch (err) {
        console.error("Receipt generation failed:", err);
      }
      return;
    }

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

async function handleCheckoutCompleted(session) {
  // Retrieve line items from Stripe
  const lineItems = await stripe.checkout.sessions.listLineItems(session.id);

  // Build the variables for the receipt template
  const variables = {
    receipt_number: session.payment_intent,
    customer: {
      name: session.customer_details.name,
      email: session.customer_details.email,
      address: formatAddress(session.customer_details.address),
    },
    company: {
      name: "Your Company Name",
      address: "123 Main St, City, Country",
      email: "billing@yourcompany.com",
    },
    line_items: lineItems.data.map((item) => ({
      description: item.description,
      quantity: item.quantity,
      unit_price: formatCurrency(item.price.unit_amount, session.currency),
      amount: formatCurrency(item.amount_total, session.currency),
    })),
    subtotal: formatCurrency(session.amount_subtotal, session.currency),
    tax: session.total_details.amount_tax
      ? formatCurrency(session.total_details.amount_tax, session.currency)
      : null,
    total: formatCurrency(session.amount_total, session.currency),
    paid_date: new Date(session.created * 1000).toLocaleDateString("en-US", {
      year: "numeric",
      month: "long",
      day: "numeric",
    }),
    payment_method: "Card",
  };

  // Generate the PDF receipt
  const pdfResponse = await fetch(
    "https://api.transactional.dev/v1/generate",
    {
      method: "POST",
      headers: {
        "x-api-token": process.env.TRANSACTIONAL_API_TOKEN,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        documentId: process.env.RECEIPT_DOCUMENT_ID,
        variables,
      }),
    }
  );

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

  const { url: pdfUrl } = await pdfResponse.json();

  // Send the receipt by email (using your email provider)
  await sendReceiptEmail({
    to: session.customer_details.email,
    subject: `Receipt for your purchase - ${variables.receipt_number}`,
    pdfUrl,
  });

  console.log(`Receipt generated and sent: ${pdfUrl}`);
}

function formatCurrency(amount, currency) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: currency.toUpperCase(),
  }).format(amount / 100);
}

function formatAddress(address) {
  if (!address) return null;
  return [address.line1, address.line2, address.city, address.postal_code, address.country]
    .filter(Boolean)
    .join(", ");
}

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

This is the entire flow. Stripe sends the event, you verify the signature, extract the payment data, call the PDF API, and email the result. The PDF generation is a single HTTP POST that returns a URL to the rendered document.

Implementation notes

A few things to get right before shipping this to production:

Idempotency

Stripe can send the same webhook event more than once. If your receipt generation or email sending is not idempotent, you will send duplicate receipts. Two ways to handle this:

  • Track processed events: Store the event.id in your database. Skip if already processed.
  • Use the payment intent ID: Check if a receipt was already generated for that payment_intent before generating a new one.

Async processing

The webhook handler above responds to Stripe immediately (res.status(200)) and processes the receipt in the background. This is important. Stripe expects a response within a few seconds. If your PDF generation or email sending is slow, Stripe will assume the webhook failed and retry.

For higher reliability, push the event to a queue (SQS, Bull, Inngest) and process it asynchronously. This decouples the webhook response from the actual work.

Storage

The PDF API returns a URL to the generated document. Decide early whether you want to:

  • Use the URL directly: Link to it in your email. Simple, but you depend on the API's storage.
  • Download and store: Fetch the PDF and store it in S3 or your own storage. More control, works for long-term archival.
  • Both: Store a copy and use the URL as a short-term convenience.

Error handling

PDF generation can fail. Your email provider can fail. Build retry logic around both. Log failures with enough context (session ID, customer email, event ID) to debug later. A dead letter queue for failed receipts is worth setting up early.

Template versioning

If you update your receipt template, previously generated PDFs are unaffected. But think about whether you need to keep old templates around for re-generation. Some businesses need to reproduce historical receipts exactly as they were issued.

Conclusion

Generating PDF receipts from Stripe payments does not have to be a side project in itself. The pattern is straightforward: listen for the webhook, map the payment data to template variables, call an API, get a PDF. No Chromium, no layout engine, no rendering infrastructure.

The receipt template is just HTML and Tailwind. When your designer wants to update the layout, they edit HTML. When your billing logic changes, you update the variable mapping. The two concerns stay separate.

If you want to avoid maintaining your own PDF rendering stack, Transactional.dev gives you a template-based PDF API that works like transactional email. Define your template once, send variables, get a PDF back.