Introduction
By Day 40 of our Payment Engineering series, you've likely handled payments, subscriptions, and customer data. Now it's time to master the other half of the transaction: billing. Invoices with Stripe: a practical Payments & Stripe guide covers everything you need to bill customers professionally, from creating line items to automating reminders and delivering PDFs. Whether you're building an e-commerce platform, SaaS product, or marketplace, Stripe's invoicing API gives you the tools to manage the entire billing lifecycle without custom PDF generation or email infrastructure.
Why invoicing matters in production systems
Invoices are more than receipts—they're legal documents, accounting records, and customer touchpoints. They affect churn, support load, and compliance. A customer who can't find their invoice or doesn't understand their charges is more likely to dispute payments or cancel. In production, we've seen invoice-related support tickets consume 15–20% of billing support time. Automating invoice creation, delivery, and reminders cuts this significantly while giving customers a professional experience.
Create invoices with line items
The foundation of Stripe invoicing is the Invoice object and Invoice Line Items. Unlike Payment Intents, which are one-off transactions, invoices are persistent records with mutable state: they can be drafted, sent, paid, or voided. Line items describe what you're charging for—a subscription renewal, a one-off service, usage overage, or a discount.
Let's walk through creating an invoice for a customer with multiple line items. Start by creating a customer if you don't have one, then add invoice line items, and finalize the invoice.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function createInvoiceWithLineItems(customerId: string) {
// Create the invoice (starts in draft state)
const invoice = await stripe.invoices.create({
customer: customerId,
collection_method: 'send_invoice', // or 'charge_automatically'
days_until_due: 30,
description: 'Monthly service charges',
});
// Add line items to the invoice
const lineItem1 = await stripe.invoiceItems.create({
customer: customerId,
invoice: invoice.id,
amount: 9900, // $99.00
currency: 'usd',
description: 'Professional plan – monthly subscription',
});
const lineItem2 = await stripe.invoiceItems.create({
customer: customerId,
invoice: invoice.id,
amount: 2500, // $25.00
currency: 'usd',
description: 'API usage overage (150 extra calls)',
});
// Finalize the invoice (move from draft to open)
const finalizedInvoice = await stripe.invoices.finalizeInvoice(invoice.id);
console.log(`Invoice ${finalizedInvoice.id} created with total: ${finalizedInvoice.total}`);
return finalizedInvoice;
}
Key points: collection_method controls how payment is collected—send_invoice emails the customer a link; charge_automatically attempts payment with a saved payment method. Days_until_due sets the payment deadline. Once you finalize an invoice, line items are locked and the invoice moves to "open" state, ready to send or charge.
In a real SaaS scenario, you'd create invoices programmatically each billing cycle. Many teams iterate invoices at the end of the month, add usage overages or seat changes as line items, then finalize and send. This keeps the audit trail clean and customers informed.
Hosted invoice pages
Stripe provides a hosted invoice page (the hosted_invoice_url field) that you can send to customers or embed in your dashboard. This page is secure, requires no custom styling, and automatically handles payment acceptance if the invoice is unpaid.
When you finalize an invoice, Stripe generates the hosted URL. You can then email it to the customer or display it in your billing portal.
async function sendInvoiceToCustomer(invoiceId: string, customerEmail: string) {
const invoice = await stripe.invoices.retrieve(invoiceId);
if (!invoice.hosted_invoice_url) {
throw new Error('Invoice does not have a hosted URL');
}
// In production, use your email service (SendGrid, Mailgun, etc.)
console.log(`Send email to ${customerEmail}:`);
console.log(`Your invoice is ready: ${invoice.hosted_invoice_url}`);
// Optional: log this for auditing
await logInvoiceAction(invoiceId, 'sent', customerEmail);
return invoice.hosted_invoice_url;
}
async function logInvoiceAction(invoiceId: string, action: string, context: string) {
// Store in your audit log table
console.log(`[Audit] Invoice ${invoiceId} – ${action} – ${context}`);
}
The hosted page handles payment entry, error messages, and confirmation. Customers can pay directly from the link without leaving Stripe. You control whether they see a card form or ACH option based on your Stripe account settings.
For a custom billing dashboard, you can also display key invoice details in your app and link to the hosted page for payment, keeping the UX within your product while delegating the payment form to Stripe.
Automatic reminders
Unpaid invoices cost money. Stripe's automatic email reminders reduce manual follow-up and improve payment rates. You can configure reminder settings at the account level or per invoice.
In your Stripe Dashboard, go to Settings > Billing settings to enable automatic reminders. Stripe can send up to three reminder emails: one at invoice creation, one partway through the due period, and a final notice before overdue.
Programmatically, you control reminders via the pending_invoice_items_behavior and webhook events. Here's how to respond to unpaid invoice events in production:
import express from 'express';
const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
res.status(400).send(`Webhook error: ${err.message}`);
return;
}
if (event.type === 'invoice.payment_failed') {
const invoice = event.data.object;
console.log(`Invoice ${invoice.id} payment failed for customer ${invoice.customer}`);
// Mark in your DB, send custom reminder, or trigger escalation
await handleFailedPayment(invoice.id, invoice.customer);
}
if (event.type === 'invoice.payment_succeeded') {
const invoice = event.data.object;
console.log(`Invoice ${invoice.id} paid`);
// Update your DB, trigger fulfillment, or send receipt
await handleSuccessfulPayment(invoice.id, invoice.customer);
}
res.json({ received: true });
});
async function handleFailedPayment(invoiceId: string, customerId: string) {
// Send custom reminder, check dunning settings, or pause service
console.log(`Custom action for failed payment: ${invoiceId}`);
}
async function handleSuccessfulPayment(invoiceId: string, customerId: string) {
// Update subscription status, unlock features, log revenue
console.log(`Payment received for ${invoiceId}`);
}
Listen to invoice.payment_failed, invoice.payment_action_required, and invoice.payment_succeeded events. In your handler, you can customize dunning (retry) logic, send alerts to your internal team, or update your fulfillment system. Stripe's built-in reminders are convenient, but webhooks let you sync Stripe state with your database.
PDF downloads for customers
Most invoices in production need a downloadable PDF. Stripe provides the invoice_pdf endpoint, which returns a pre-rendered PDF. This is the simplest approach: no third-party PDF library, no formatting headaches.
import { Response } from 'express';
async function downloadInvoicePDF(invoiceId: string, res: Response) {
try {
const pdfBuffer = await stripe.invoices.retrievePdf(invoiceId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="invoice-${invoiceId}.pdf"`);
res.send(pdfBuffer);
} catch (err) {
res.status(404).json({ error: 'Invoice PDF not found' });
}
}
// Express route
app.get('/invoices/:id/pdf', async (req, res) => {
await downloadInvoicePDF(req.params.id, res);
});
The PDF is generated server-side and streamed to the client. It's PCI-compliant, includes all line items, taxes, and customer details, and matches your Stripe branding. You can customize colors and logos in your Stripe Dashboard under Branding > Default.
For more control, some teams use Stripe's invoice data to render PDFs locally with libraries like pdfkit or puppeteer. This is useful if you want to add custom sections, include additional metadata from your database, or match a specific corporate template. However, this adds complexity and maintenance overhead—Stripe's hosted PDF covers 80% of use cases.
Common mistakes
- Creating invoices without finalizing them: Draft invoices don't send emails and don't appear on hosted pages. Always call
finalizeInvoice()after adding line items, or setauto_advance: trueduring creation. - Forgetting to handle
invoice.payment_action_required: If a card requires 3D Secure or other authentication, the payment doesn't complete immediately. Listen for this event and prompt the customer to authenticate. - Not validating invoice totals before sending: If you add line items manually, ensure the invoice total matches your expected amount. A missing line item or wrong currency is a red flag.
- Storing PDF bytes instead of URLs: PDFs expire and change. Instead, store the invoice ID and fetch the PDF on demand. This keeps your database lean and ensures customers always see the latest version.
- Ignoring timezone issues: Invoice dates and due dates are stored in UTC. When displaying them to customers, convert to their local timezone or explicitly state UTC.
Key takeaways
- Stripe invoices are persistent, mutable records with clear state transitions (draft → open → paid/voided). Create them with line items, finalize, and send via hosted URL or email.
- Use
collection_method: 'send_invoice'for manual payment (email link), andcharge_automatically'for automatic charging with a saved payment method. - Hosted invoice pages handle payment entry, authentication, and confirmation—no custom payment form needed. You can link to them from your dashboard or email.
- Enable Stripe's automatic email reminders in account settings, then listen to payment-related webhooks to sync invoice state with your database and trigger custom actions.
- Use Stripe's
retrievePdf()endpoint for PDFs. It's simple, compliant, and requires no third-party rendering. Store invoice IDs, not PDF bytes. - Validate invoice totals, handle
payment_action_requiredevents, and always finalize invoices before sending. Test your invoice workflow end-to-end before production.






Comments
Loading comments…