Skip to content
CastPDF

Custom Stripe invoice PDFs with your own layout

Stripe creates its own invoice PDFs, with a few branding options. For full control of the layout, listen for the invoice.paid event on your server, verify its signature, map the invoice fields to your template data, and request the PDF from our invoice PDF API. Then store the link or email it.

  • Updated October 2026
  • 100 free PDFs a month

What Stripe already gives you

Stripe Billing produces an invoice PDF and a hosted invoice page for every finalized invoice. In its branding settings you can add a logo and an icon and pick brand and accent colours. Invoice settings add text such as a memo, a footer and a few custom fields. For many businesses that is enough, and you should use it.

The layout itself stays Stripe’s. You cannot move blocks around, change the columns of the line table, add a second language, print a project summary or match the look of the quotes and contracts you already send. When the invoice has to look like the rest of your documents, you need to produce the PDF yourself.

Plainly put

CastPDF has no Stripe app, plugin or marketplace listing. What follows is your own server code calling two APIs: Stripe sends your server an event, and your server asks CastPDF for a PDF.

How the pieces fit together

  1. A customer pays, and Stripe marks the invoice as paid.
  2. Stripe sends an invoice.paid event to a URL on your server. In Stripe’s words, this is a webhook endpoint.
  3. Your server checks the signature, so it knows the event really came from Stripe.
  4. It turns the invoice object into the JSON your template expects.
  5. It calls POST /v1/pdf with your template id, that data and the invoice id as the idempotency key.
  6. It stores the PDF link with the invoice, or emails it to the customer.

Pick the event that matches your document. invoice.paid suits a paid invoice or receipt. invoice.finalized fires earlier, when the invoice gets its number and is ready to send for payment. Handle one of them per document type, not both with the same key, for a reason explained in the retry section.

Set it up in five steps

  1. Save an invoice template. Create a template in the CastPDF dashboard, for example from the invoice starter. Make its fields match the data your server will send.
  2. Add an event route to your server. Add a POST route that reads the raw request body and verifies the Stripe signature with your endpoint secret.
  3. Subscribe to the invoice event. In the Stripe Dashboard developer settings, add an event destination with your route URL and select invoice.paid.
  4. Map the invoice and request the PDF. Convert amounts from the smallest currency unit, build the template data, and call the CastPDF API with the invoice id as Idempotency-Key.
  5. Store or send the result. Save the signed link and document id with your invoice record, or email the PDF to your customer from your own mail service.

Mapping Stripe invoice fields to template data

A Stripe invoice object has dozens of fields. You need about ten. The table shows the usual ones and what to do with each:

Stripe invoice fields and your template data
Stripe fieldTemplate dataWatch out for
numberinvoice.numberEmpty on drafts. It is set when the invoice is finalized.
customer_name, customer_emailcustomer.name, customer.emailEither can be empty. Give the template a fallback.
currencycurrencyLower case, such as usd. Upper-case it for the money filter.
lines.data[].descriptionitems[].descriptionOnly the first lines are embedded. Fetch the rest when has_more is true.
lines.data[].quantityitems[].quantityMay be empty on some line types. Default it to 1.
lines.data[].amountitems[].amountIn the smallest unit: cents for USD. Divide by 100.
subtotal, totalsubtotal, totalAlso in the smallest unit. Stripe already applied discounts.
total_taxes (older API versions: tax)taxRecent API versions list taxes as an array. Add up its amounts.
createdinvoice.issuedUnix seconds. Multiply by 1000 or convert to an ISO date.

The amounts deserve care. Stripe stores money as whole numbers in the smallest unit of the currency, so $1,250.00 arrives as 125000. For two-decimal currencies such as USD, EUR and GBP you divide by 100. Zero-decimal currencies such as JPY and KRW are already whole units, so you leave them alone. Stripe’s currency documentation lists every special case, including a few three-decimal currencies.

Note that the template prints Stripe’s totals rather than computing its own. Stripe has already applied coupons, credits and tax rules. Recomputing them in Liquid could produce a total that disagrees with the payment by a cent, which is exactly what an invoice must never do.

The server code (Node.js and Express)

This example uses Express and the official stripe package from npm (npm install express stripe). It needs Node.js 18 or later for the built-in fetch. CastPDF has no package of its own: the call to our API is a plain HTTPS request.

server.mjs
import express from 'express';
import Stripe from 'stripe';

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

// Currencies Stripe counts in whole units. See Stripe's currency docs for the full list.
const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']);
const toMajor = (amount, currency) => (ZERO_DECIMAL.has(currency) ? amount : amount / 100);

// The raw body is required for the signature check, so this route must not use express.json().
app.post('/stripe/events', 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) {
    return res.status(400).send(`Signature check failed: ${err.message}`);
  }

  if (event.type !== 'invoice.paid') return res.sendStatus(200);

  const invoice = event.data.object;
  try {
    const doc = await renderInvoice(invoice);
    await saveInvoicePdf(invoice.id, doc.id, doc.url); // your database, or your own email step
    res.sendStatus(200);
  } catch (err) {
    console.error(err);
    res.sendStatus(500); // Stripe retries; the Idempotency-Key makes the retry safe
  }
});

async function allLines(invoice) {
  if (!invoice.lines.has_more) return invoice.lines.data;
  const lines = [];
  for await (const line of stripe.invoices.listLineItems(invoice.id, { limit: 100 })) lines.push(line);
  return lines;
}

function toTemplateData(invoice, lines) {
  const currency = invoice.currency;
  const tax = Array.isArray(invoice.total_taxes)
    ? invoice.total_taxes.reduce((sum, t) => sum + t.amount, 0)
    : (invoice.tax ?? 0);
  return {
    currency: currency.toUpperCase(),
    locale: 'en-US',
    invoice: { number: invoice.number, issued: new Date(invoice.created * 1000).toISOString() },
    customer: { name: invoice.customer_name, email: invoice.customer_email },
    items: lines.map((line) => ({
      description: line.description,
      quantity: line.quantity ?? 1,
      amount: toMajor(line.amount, currency),
    })),
    subtotal: toMajor(invoice.subtotal, currency),
    tax: toMajor(tax, currency),
    total: toMajor(invoice.total, currency),
  };
}

async function renderInvoice(invoice) {
  const data = toTemplateData(invoice, await allLines(invoice));
  const res = await fetch('https://api.castpdf.com/v1/pdf', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': invoice.id,
    },
    body: JSON.stringify({
      template_id: process.env.CASTPDF_INVOICE_TEMPLATE_ID,
      data,
      filename: `${invoice.number}.pdf`,
      response: 'url',
      metadata: { stripe_invoice: invoice.id },
    }),
    signal: AbortSignal.timeout(60_000),
  });
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  return res.json();
}

async function saveInvoicePdf(invoiceId, documentId, url) {
  console.log('PDF ready for', invoiceId, documentId, url);
}

app.listen(3000);

Three details in that file cause most failed setups. First, the route uses express.raw, because the signature is computed over the exact bytes Stripe sent. If a global app.use(express.json()) runs before it, the body is parsed and re-serialized, and constructEvent rejects every event. Second, the endpoint secret is the one Stripe shows for this specific endpoint, not your API key. Third, the route answers 200 for event types it ignores, so Stripe does not keep retrying them.

The template side

Start from the invoice template and change two things. Replace its item columns with the fields above, and replace its computed totals with the values Stripe sends. A trimmed version of the totals and lines looks like this:

Lines and totals from Stripe data
<p>Billed to {{ customer.name | default: customer.email }}</p>

{% for item in items %}
<tr>
  <td>{{ item.description }}</td>
  <td class="num">{{ item.quantity }}</td>
  <td class="num">{{ item.amount | money: currency, locale }}</td>
</tr>
{% endfor %}

<div class="keep-together">
  <p>Subtotal {{ subtotal | money: currency, locale }}</p>
  <p>Tax {{ tax | money: currency, locale }}</p>
  <p><strong>Total {{ total | money: currency, locale }}</strong></p>
</div>

The default filter covers customers without a saved name. The money filter handles the symbol and decimals per currency, so a yen invoice prints without cents automatically. The HTML invoice template guide explains the print rules behind the rest of the layout, and the Liquid reference lists every filter.

Retries, duplicates and the idempotency key

Stripe may deliver the same event more than once, and it retries deliveries that fail or time out. Without care, each delivery would produce another PDF and count against your plan. Sending the Stripe invoice id, such as in_1Q2w3E4r5T6y, as the Idempotency-Key stops that. A repeat request with the same key and body returns the first document without rendering again.

  • Build the data only from the invoice itself. A timestamp from Date.now() in the body would make every retry look different and get 422 idempotency_mismatch.
  • If you handle both invoice.finalized and invoice.paid, give them different keys, such as the id plus -paid. Their data differs, so one shared key would clash.
  • CastPDF remembers a key for at least 24 hours. Stripe can keep retrying for longer, so also record in your database that an invoice already has its PDF, and skip it next time.

Rendering usually takes a few seconds, and never more than 30. Stripe expects a quick reply, so for very long invoices you may prefer to answer 200 at once and render from a job queue in your own app. The simple version above renders inline and returns 500 on failure, so Stripe tries again.

Testing locally

The Stripe CLI can forward events to your laptop and fire test events on demand. Use a Stripe test mode key together with a CastPDF test key: the PDFs are free and carry a watermark.

Forward and trigger test events
# terminal 1: forward events to your local server (prints a temporary endpoint secret)
stripe listen --forward-to localhost:3000/stripe/events

# terminal 2: create a test invoice and pay it
stripe trigger invoice.paid

The listen command prints a temporary endpoint secret. Put it in STRIPE_WEBHOOK_SECRET while you test, and switch to the secret of your real endpoint in production.

Storing and sending the PDF

The response holds a signed url that works without a key until expires_at. Save the document id with your invoice record. Later you can fetch a fresh link from GET /v1/pdf/:id while the file is kept, or copy the file into your own storage on day one if you need it for years.

To email it, attach the file or link from your own mail service. CastPDF does not send email. Decide too whether customers should still receive Stripe’s own invoice email, so they do not get two different versions. The API reference has every response field, and the invoice from JSON guide covers numbering, rounding and currencies in more depth.

FAQ

Common questions

Can I change the layout of the invoice PDF that Stripe creates?

Only within its branding and invoice settings, such as logo, colours, memo and footer. The layout itself is fixed. For a fully custom design, render the PDF yourself from the invoice data.

Is there a CastPDF app for Stripe?

No. CastPDF has no Stripe app, plugin or listing. You connect the two with a small piece of your own server code that receives Stripe events and calls the CastPDF API.

Which Stripe event should trigger the invoice PDF?

Use invoice.paid for a paid invoice or receipt. Use invoice.finalized if you want the PDF as soon as the invoice is numbered and ready to send for payment.

Why are Stripe invoice amounts 100 times too large?

Stripe stores amounts in the smallest currency unit, such as cents. Divide by 100 for two-decimal currencies like USD and EUR. Leave zero-decimal currencies like JPY unchanged.

How do I avoid duplicate PDFs when Stripe resends an event?

Send the Stripe invoice id as the Idempotency-Key header. A repeated request with the same key and data returns the first PDF and is not counted again.

Why does the Stripe signature check fail on my server?

Usually because a JSON body parser ran before the check. Stripe signs the raw bytes, so the route must read the raw body and use the secret of that exact endpoint.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.