PDFPipe

Guide

PDF receipts from a Stripe webhook

Every time a Stripe payment succeeds, you can generate a branded PDF receipt, store its URL, and email it to the customer without any manual steps. The webhook handler receives the invoice.paid event, builds an HTML invoice from the line items, converts it to a PDF, and delivers it in a single request flow. No cron jobs, no separate invoicing service.

Basic webhook handler

The handler below works with Express or a Next.js App Router POST route. It verifies the Stripe signature, extracts the customer email and line items from the invoice.paid event, builds an HTML invoice, converts it to a stored PDF, and hands the URL off to the email and metadata steps:

typescript
// npm install stripe
// Works with Express, Next.js API routes, or any Node.js HTTP handler.
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const PDFPIPE_KEY = process.env.PDFPIPE_API_KEY!;

// Express example — adapt the body parsing to your framework.
// For Next.js App Router: export async function POST(req: Request) { ... }
export async function stripeWebhookHandler(req: Request): Promise<Response> {
  const payload = await req.text();
  const sig = req.headers.get("stripe-signature") ?? "";

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response(`Webhook signature verification failed`, { status: 400 });
  }

  if (event.type === "invoice.paid") {
    const invoice = event.data.object as Stripe.Invoice;

    const customerEmail = typeof invoice.customer_email === "string"
      ? invoice.customer_email
      : "";
    const invoiceNumber = invoice.number ?? invoice.id;
    const amountPaid = (invoice.amount_paid / 100).toFixed(2);
    const currency = invoice.currency.toUpperCase();

    // Build the line items table from invoice line items.
    const lineItemRows = invoice.lines.data.map((line) => {
      const unitAmount = ((line.price?.unit_amount ?? 0) / 100).toFixed(2);
      const lineTotal = (line.amount / 100).toFixed(2);
      return `
        <tr>
          <td style="padding:8px 12px;border-bottom:1px solid #e5e0d8;">${line.description ?? "Item"}</td>
          <td style="padding:8px 12px;border-bottom:1px solid #e5e0d8;text-align:right;">${line.quantity ?? 1}</td>
          <td style="padding:8px 12px;border-bottom:1px solid #e5e0d8;text-align:right;">${currency} ${unitAmount}</td>
          <td style="padding:8px 12px;border-bottom:1px solid #e5e0d8;text-align:right;">${currency} ${lineTotal}</td>
        </tr>`;
    }).join("");

    const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <style>
    body { font-family: Archivo, sans-serif; color: #1d1812; background: #f5efe2; margin: 0; padding: 40px; }
    h1   { font-family: Fraunces, Georgia, serif; font-size: 2rem; margin-bottom: 4px; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th { padding: 8px 12px; text-align: left; background: #1d1812; color: #f5efe2; }
    .total { font-weight: 700; font-size: 1.1rem; }
  </style>
</head>
<body>
  <h1>Invoice Receipt</h1>
  <p>Invoice #${invoiceNumber}</p>
  <p>Billed to: ${customerEmail}</p>
  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th style="text-align:right">Qty</th>
        <th style="text-align:right">Unit price</th>
        <th style="text-align:right">Amount</th>
      </tr>
    </thead>
    <tbody>${lineItemRows}</tbody>
  </table>
  <p class="total" style="text-align:right;margin-top:16px;">
    Total paid: ${currency} ${amountPaid}
  </p>
</body>
</html>`;

    const pdfRes = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${PDFPIPE_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        html,
        options: { format: "A4" },
        store: true,
        filename: `invoice-${invoiceNumber}.pdf`,
      }),
    });

    if (!pdfRes.ok) {
      console.error("PDF generation failed", await pdfRes.text());
      return new Response("PDF generation failed", { status: 502 });
    }

    await pdfRes.arrayBuffer(); // drain so the connection is released

    const documentUrl = pdfRes.headers.get("X-PDFPipe-Document-Url") ?? "";

    await sendInvoiceEmail(customerEmail, documentUrl, invoiceNumber);
    await stripe.invoices.update(invoice.id, {
      metadata: { pdf_url: documentUrl },
    });
  }

  return new Response("ok", { status: 200 });
}

Passing store: true keeps the document on the CDN and returns its URL in the X-PDFPipe-Document-Url response header. The URL is stable for 30 days and can be shared in emails or saved to your database.

Email the PDF to the customer

Rather than attaching the PDF directly (which inflates email size and can trigger spam filters), send the customer a download link. The sendInvoiceEmail helper below uses Resend, but the pattern is identical for MailerSend, SendGrid, or Postmark: swap the fetch call for the relevant SDK method:

typescript
// sendInvoiceEmail.ts
// Works with any transactional email provider. Swap the fetch call for the SDK
// of your choice (Resend, MailerSend, SendGrid, Postmark, etc.).

export async function sendInvoiceEmail(
  to: string,
  documentUrl: string,
  invoiceNumber: string | null,
): Promise<void> {
  if (!to) return;

  // Resend example (npm install resend):
  const res = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "[email protected]",
      to,
      subject: `Your invoice ${invoiceNumber ?? ""} is ready`,
      html: `
        <p>Thank you for your payment.</p>
        <p>
          <a href="${documentUrl}">Download your invoice PDF</a>
        </p>
        <p>The link is valid for 30 days.</p>
      `,
    }),
  });

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Email send failed: ${body}`);
  }
}

// Using a download link (not an attachment) keeps the email small and lets the
// customer access the invoice from any device without downloading a file.

Save the URL to Stripe metadata

Writing the PDF URL back to the Stripe Invoice as metadata makes it retrievable from the Stripe dashboard, the API, and any other service that already reads Stripe data. It also eliminates the need for a separate invoices table if your use case is simple:

typescript
// After generating the PDF, write the URL back to the Stripe Invoice.
// This makes it retrievable from the Stripe dashboard and via the API.

await stripe.invoices.update(invoice.id, {
  metadata: {
    pdf_url: documentUrl,
  },
});

// ---
// To read it back later:
const inv = await stripe.invoices.retrieve("in_xxx");
const pdfUrl = inv.metadata?.pdf_url;

// You can also store it on the Stripe Customer for easy lookup:
await stripe.customers.update(invoice.customer as string, {
  metadata: { latest_invoice_pdf: documentUrl },
});

Registering the webhook endpoint

For local development, the Stripe CLI forwards live events to your machine. For production, add the endpoint URL in the Stripe dashboard under Developers > Webhooks and subscribe to the invoice.paid event (or payment_intent.succeeded if you are not using invoices):

sh
# Forward Stripe events to your local server (requires the Stripe CLI).
# Install: https://stripe.com/docs/stripe-cli
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# The CLI prints a webhook signing secret that starts with "whsec_".
# Set it as STRIPE_WEBHOOK_SECRET in your .env for local testing.

# Trigger a test event to verify your handler:
stripe trigger invoice.paid

Environment variables

All secrets go in your .env file and are loaded at startup. Never commit them to source control:

sh
# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...   # from "stripe listen" output (local) or dashboard (prod)
PDFPIPE_API_KEY=your_api_key_here
RESEND_API_KEY=re_...             # or your preferred email provider key

Getting an API key

The Hobby plan gives 500 free documents a month with no credit card required. Paid plans start at $19 and include a longer document archive, higher limits, and email support.

Full API reference on the docs page. Try it now in the live playground with no signup.

Get an API key →