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
- A customer pays, and Stripe marks the invoice as paid.
- Stripe sends an
invoice.paidevent to a URL on your server. In Stripe’s words, this is a webhook endpoint. - Your server checks the signature, so it knows the event really came from Stripe.
- It turns the invoice object into the JSON your template expects.
- It calls
POST /v1/pdfwith your template id, that data and the invoice id as the idempotency key. - 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
- 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.
- 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.
- Subscribe to the invoice event. In the Stripe Dashboard developer settings, add an event destination with your route URL and select
invoice.paid. - 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. - 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 field | Template data | Watch out for |
|---|---|---|
number | invoice.number | Empty on drafts. It is set when the invoice is finalized. |
customer_name, customer_email | customer.name, customer.email | Either can be empty. Give the template a fallback. |
currency | currency | Lower case, such as usd. Upper-case it for the money filter. |
lines.data[].description | items[].description | Only the first lines are embedded. Fetch the rest when has_more is true. |
lines.data[].quantity | items[].quantity | May be empty on some line types. Default it to 1. |
lines.data[].amount | items[].amount | In the smallest unit: cents for USD. Divide by 100. |
subtotal, total | subtotal, total | Also in the smallest unit. Stripe already applied discounts. |
total_taxes (older API versions: tax) | tax | Recent API versions list taxes as an array. Add up its amounts. |
created | invoice.issued | Unix 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.
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:
<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 get422 idempotency_mismatch. - If you handle both
invoice.finalizedandinvoice.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.
# 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.paidThe 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.