Get started
Build a Stripe PDF invoice generator with webhooks (working repo)

Build a Stripe PDF invoice generator with webhooks (working repo)

Build a custom Stripe PDF invoice flow with webhooks: Stripe payment.succeeded → render branded PDF via PDF4.dev → email link. Full working repo, 80 lines.

12 min read

You can build a branded Stripe PDF invoice flow in roughly 80 lines of Node.js. Stripe sends a payment_intent.succeeded webhook, your server verifies the signature, fetches the customer and line items, posts the data to PDF4.dev's /api/v1/render endpoint, receives a signed URL, and emails that URL to the customer. End-to-end cost: about $0.005 per invoice (PDF4.dev render plus Resend email at low volume), versus $15 to $49 per month for a SaaS that does the same thing.

This tutorial walks through every step with full working code: the webhook handler, signature verification, the data builder, the Handlebars template, and the email send. By the end, you have a production-grade pipeline you can drop into any Node.js or TypeScript app, no third-party SaaS subscription required.

ComponentWhat it doesCost at low volume
Stripe webhookTrigger on payment successFree
PDF4.dev renderHTML → PDF$0.004 per render
Resend emailDeliver the URL$0.001 per email
Total per invoice$0.005

Why Stripe's built-in receipts aren't enough

Stripe's built-in receipts are HTML emails generated by Stripe and sent directly to the customer. They are not branded beyond a logo and accent color, they are not stored as PDF files, and there is no API to download them. For consumer charges this is fine. For B2B SaaS, marketplaces, or any business that has to hand a real PDF to accounting, it is not.

Per Stripe's receipt documentation, the hosted receipt is an HTML page with a public URL. You can convert it to PDF in a headless browser, but you inherit Stripe's layout, Stripe's branding constraints, and a fragile dependency on a page Stripe controls. Subscription invoices have a hosted_invoice_url and an invoice_pdf field, but one-off PaymentIntents and Checkout Sessions do not.

The pragmatic answer is to render your own PDF. You get full control over the design, you store the file in your own bucket, and you can include anything Stripe does not expose by default: VAT breakdowns, purchase order numbers, custom tax notices, multi-language formatting, accounting codes. The build cost is one afternoon. The recurring cost is half a cent per invoice.

The complete pipeline at a glance

The flow has five steps, each isolated so you can swap pieces without rewriting the rest. Stripe → your webhook → PDF4.dev → email → audit log.

  1. Stripe sends a payment_intent.succeeded event to your POST /webhooks/stripe endpoint.
  2. Your server verifies the webhook signature with stripe.webhooks.constructEvent.
  3. On success, you retrieve the PaymentIntent with expand: ['customer', 'invoice'] to pull the customer record and line items.
  4. You build a JSON data object and POST it to https://pdf4.dev/api/v1/render with template_id and delivery: "url".
  5. PDF4.dev returns { url, expires_at, size_bytes }. You email the URL to the customer with Resend, then store the URL plus Stripe event ID in your database.

Total wall-clock latency, from card swipe to email in inbox, is typically 2 to 3 seconds. Stripe's webhook delivery is in the 50-200ms range, signature verification is microseconds, the PDF render is 200-400ms, and the email send is 100-300ms. Everything else is your own request handling.

Step 1: Stripe webhook endpoint (Node.js)

The webhook handler is an Express POST route that accepts the raw request body, verifies the Stripe signature, and dispatches on event.type. The critical detail: Stripe's signature is computed over the raw bytes, so you have to use express.raw() for this one route, not express.json().

import express from 'express';
import Stripe from 'stripe';
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
 
const app = express();
 
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'] as string;
 
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(req.body, signature, webhookSecret);
    } catch (err) {
      console.error('Webhook signature verification failed:', err);
      return res.status(400).send('Invalid signature');
    }
 
    // Acknowledge immediately, then process async to keep webhook latency under 1s
    res.json({ received: true });
 
    if (event.type === 'payment_intent.succeeded') {
      const paymentIntent = event.data.object as Stripe.PaymentIntent;
      await handlePaymentSucceeded(paymentIntent, event.id);
    }
  }
);
 
app.listen(3000);

Set STRIPE_WEBHOOK_SECRET from the Stripe dashboard under Developers → Webhooks → your endpoint → "Signing secret". Local testing uses the Stripe CLI: run stripe listen --forward-to localhost:3000/webhooks/stripe and Stripe prints a temporary secret for the session.

Always respond 200 before starting the PDF render. Stripe's webhook timeout is 30 seconds, and slow handlers get marked as failed and retried. Acknowledge first, process after.

Step 2: Enrich the payment with customer and line items

PaymentIntents in the webhook payload are not fully expanded. To get the customer's email, address, and the human-readable line items, you have to retrieve the object again with the expand parameter. This adds one API round trip but gives you everything the PDF needs in a single object.

async function handlePaymentSucceeded(
  paymentIntent: Stripe.PaymentIntent,
  eventId: string
) {
  // Idempotency: skip if this event was already processed
  if (await db.processedEvents.exists(eventId)) {
    console.log(`Skipping duplicate event ${eventId}`);
    return;
  }
 
  const fullIntent = await stripe.paymentIntents.retrieve(paymentIntent.id, {
    expand: ['customer', 'latest_charge', 'invoice'],
  });
 
  const customer = fullIntent.customer as Stripe.Customer;
  const charge = fullIntent.latest_charge as Stripe.Charge;
 
  const data = {
    invoice_number: `INV-${fullIntent.created}-${fullIntent.id.slice(-6)}`,
    issue_date: new Date(fullIntent.created * 1000).toISOString().slice(0, 10),
    customer_name: customer.name ?? 'Customer',
    customer_email: customer.email ?? '',
    customer_address: formatAddress(customer.address),
    currency: fullIntent.currency.toUpperCase(),
    locale: customer.preferred_locales?.[0] ?? 'en-US',
    items: extractLineItems(fullIntent),
    subtotal: (fullIntent.amount - (charge.application_fee_amount ?? 0)) / 100,
    total: fullIntent.amount / 100,
    payment_method: charge.payment_method_details?.card?.brand ?? 'card',
    last4: charge.payment_method_details?.card?.last4 ?? '',
  };
 
  await renderAndEmail(data, customer.email!, eventId);
  await db.processedEvents.insert({ id: eventId, processed_at: new Date() });
}

The expand parameter is documented in Stripe's API reference. Each expanded field adds latency proportional to the size of the expanded object, so only expand what the PDF actually needs. For most invoices, customer, latest_charge, and (for subscriptions) invoice are enough.

Step 3: Build the data object and call PDF4.dev

With the enriched payment in hand, the render call is a single POST. Use delivery: "url" so PDF4.dev returns a signed URL instead of a base64 blob, which keeps the response under 1 KB and avoids holding the PDF bytes in your Node process.

async function renderAndEmail(
  data: InvoiceData,
  customerEmail: string,
  eventId: string
) {
  const response = await fetch('https://pdf4.dev/api/v1/render', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.PDF4_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      template_id: 'stripe-invoice',
      data,
      delivery: 'url',
    }),
  });
 
  if (!response.ok) {
    const error = await response.json();
    throw new Error(`PDF render failed: ${JSON.stringify(error)}`);
  }
 
  const { url, expires_at, size_bytes } = await response.json();
 
  await sendInvoiceEmail(customerEmail, url, data.invoice_number);
 
  await db.invoices.insert({
    stripe_event_id: eventId,
    invoice_number: data.invoice_number,
    customer_email: customerEmail,
    pdf_url: url,
    pdf_expires_at: expires_at,
    pdf_size_bytes: size_bytes,
    rendered_at: new Date(),
  });
}

The same call as a cURL one-liner, useful for testing the template before wiring up the webhook:

curl -X POST https://pdf4.dev/api/v1/render \
  -H "Authorization: Bearer p4_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "stripe-invoice",
    "delivery": "url",
    "data": {
      "invoice_number": "INV-001",
      "issue_date": "2026-07-15",
      "customer_name": "Acme Corp",
      "customer_email": "[email protected]",
      "currency": "USD",
      "locale": "en-US",
      "items": [
        { "description": "Pro plan, monthly", "quantity": 1, "unit_price": 49, "amount": 49 }
      ],
      "subtotal": 49,
      "total": 49,
      "payment_method": "visa",
      "last4": "4242"
    }
  }'

Step 4: Build the HTML template (Handlebars)

Save this template once in the PDF4.dev dashboard under the slug stripe-invoice. Every subsequent render call references it by template_id, so the HTML never travels over the wire. The template uses PDF4.dev's built-in formatCurrency helper to render amounts according to the customer's locale.

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: 'Inter', sans-serif; font-size: 14px; color: #1a1a1a; }
    .header { display: flex; justify-content: space-between; padding: 32px 0; border-bottom: 2px solid #1a1a1a; }
    .brand { font-size: 22px; font-weight: 700; }
    .invoice-num { font-size: 28px; font-weight: 700; color: #7c3aed; }
    .meta { margin-top: 32px; color: #6b7280; }
    .bill-to { margin: 32px 0; padding: 20px; background: #f9fafb; border-radius: 8px; }
    table { width: 100%; border-collapse: collapse; margin: 32px 0; }
    th { text-align: left; padding: 12px 16px; border-bottom: 2px solid #e5e7eb; font-size: 12px; text-transform: uppercase; color: #6b7280; }
    td { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; }
    .totals { text-align: right; margin-top: 16px; }
    .total-row { font-size: 18px; font-weight: 700; padding-top: 12px; border-top: 2px solid #1a1a1a; }
    .footer { margin-top: 48px; padding-top: 24px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 12px; }
  </style>
</head>
<body>
  <div class="header">
    <div class="brand">Acme Inc.</div>
    <div class="invoice-num">#{{invoice_number}}</div>
  </div>
 
  <div class="meta">
    Issued {{issue_date}} / Paid with {{uppercase payment_method}} ending {{last4}}
  </div>
 
  <div class="bill-to">
    <div style="font-weight: 600;">{{customer_name}}</div>
    <div>{{customer_email}}</div>
    {{#if customer_address}}<div>{{customer_address}}</div>{{/if}}
  </div>
 
  <table>
    <thead>
      <tr><th>Description</th><th>Qty</th><th style="text-align:right">Unit</th><th style="text-align:right">Amount</th></tr>
    </thead>
    <tbody>
      {{#each items}}
      <tr>
        <td>{{description}}</td>
        <td>{{quantity}}</td>
        <td style="text-align:right">{{formatCurrency unit_price ../currency ../locale}}</td>
        <td style="text-align:right">{{formatCurrency amount ../currency ../locale}}</td>
      </tr>
      {{/each}}
    </tbody>
  </table>
 
  <div class="totals">
    <div>Subtotal: {{formatCurrency subtotal currency locale}}</div>
    {{#if tax_amount}}<div>Tax: {{formatCurrency tax_amount currency locale}}</div>{{/if}}
    <div class="total-row">Total: {{formatCurrency total currency locale}}</div>
  </div>
 
  <div class="footer">
    Thank you for your business. Questions? Reply to this email or contact [email protected].
  </div>
</body>
</html>

Two things to notice. First, {{formatCurrency amount currency locale}} formats numbers according to the locale string: 1500 becomes $1,500.00 for en-US and 1 500,00 € for fr-FR. Second, {{uppercase payment_method}} is a built-in helper that turns visa into VISA. The full list of helpers is in the PDF4.dev docs.

Step 5: Email the PDF URL via Resend

Resend's API takes a to, from, subject, and html body. To attach the PDF, you can either inline the URL as a download link or pass an attachments array with the URL and a filename. The URL approach is simpler and avoids holding the PDF bytes in the email payload.

import { Resend } from 'resend';
 
const resend = new Resend(process.env.RESEND_API_KEY);
 
async function sendInvoiceEmail(
  to: string,
  pdfUrl: string,
  invoiceNumber: string
) {
  const result = await resend.emails.send({
    from: 'Acme Billing <[email protected]>',
    to,
    subject: `Receipt for ${invoiceNumber}`,
    html: `
      <p>Thank you for your payment.</p>
      <p>Your receipt is ready:</p>
      <p><a href="${pdfUrl}">Download invoice ${invoiceNumber} (PDF)</a></p>
      <p>This link expires in 24 hours. Reply to this email if you need a fresh copy.</p>
    `,
    attachments: [
      { path: pdfUrl, filename: `${invoiceNumber}.pdf` },
    ],
  });
 
  if (result.error) {
    console.error('Resend failed:', result.error);
    await db.failedEmails.insert({
      invoice_number: invoiceNumber,
      to,
      error: result.error.message,
      retry_at: new Date(Date.now() + 5 * 60 * 1000),
    });
    return;
  }
 
  console.log(`Sent ${invoiceNumber} to ${to} (Resend id: ${result.data?.id})`);
}

Resend's Node.js SDK fetches the URL server-side and attaches the bytes to the outgoing email, so the customer gets both the inline link and a real PDF attachment. The path field on attachments accepts any HTTPS URL.

The fallback path is the important part. If Resend returns an error (rate limit, invalid recipient, transient failure), log the failure to a failed_emails table with a retry_at timestamp and have a worker retry every 5 minutes for an hour, then alert. Never throw from the webhook handler at this point: the PDF was already rendered and stored, so retrying the entire pipeline would render it again and waste a render credit.

Production tips

The 80 lines above work. Going from "works" to "works at 3 AM on Black Friday" needs a handful of extra patterns.

Idempotency. Stripe retries failed webhooks for up to 3 days with exponential backoff. Without idempotency, a single delivery failure plus a retry equals two invoices, two emails, two database rows. Use the event.id as a unique key in your processed_events table and short-circuit duplicates before any work happens.

Retries on PDF4.dev errors. The API can return 429 (rate limit) or 503 (transient). Wrap the render call in a retry-with-jitter loop, 3 attempts with backoff of 200ms, 1s, 5s. If all three fail, move the job to a dead-letter queue and alert. Do not silently drop the invoice.

Observability. Log every render with event.id, invoice_number, render_duration_ms, email_send_duration_ms. Alert if p99 render time goes above 2 seconds for more than 5 minutes. PDF4.dev's render endpoint returns duration_ms in the response, so you do not have to measure it yourself.

Mirror to S3. PDF4.dev's signed URLs expire after 24 hours. That is fine for the email but not for compliance. Add a step between render and email: fetch the URL, write the bytes to your own S3 or R2 bucket with a stable key like invoices/{customer_id}/{invoice_number}.pdf, and store that key in your database alongside the Stripe event ID. Long-term storage on R2 costs $0.015 per GB per month, so a typical 50 KB invoice costs $0.00000075 to keep forever.

Accounting integration. Push every successful render to your accounting system (QuickBooks, Xero, Pennylane) via their API. Most have a create_invoice endpoint that accepts the same JSON shape you used for the PDF. One render, two systems updated, zero manual data entry.

Cost comparison: build vs buy

Several SaaS products do exactly the pipeline above and charge a flat monthly fee. The math at low volume favors them. At medium volume, the build-it-yourself approach wins by an order of magnitude.

Break-even is around 300 invoices per month. Below that, a SaaS subscription is cheaper than the time you spend maintaining the integration. Above that, every additional invoice on a SaaS plan costs more than the marginal PDF4.dev render plus email.

ApproachSetup timeCost at 100 invoices/monthCost at 1,000 invoices/month
SaaS receipt builder30 min$15 to $49 flat$49 to $99 flat
Build with PDF4.dev + Resend1 afternoon$0.50 (PDF) + $0.10 (email)$5.00 + $1.00
Self-host Playwright + SMTP2 to 5 daysServer costServer cost + ops time

The build approach also gives you something the SaaS approach cannot: ownership of the template, the data, the audit trail, and the email branding. If the SaaS goes down or pivots, your invoice flow goes down with it. If PDF4.dev goes down, you have a fallback render path (a queued job that retries when the service is back), and the template HTML is portable to any Chromium-based renderer.

Frequently asked questions

Common questions about wiring Stripe webhooks to a PDF rendering pipeline, signature verification, retries, and storage.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.