Generating your own branded PDF from a Stripe invoice

Anurag Jha ·

Stripe gives every invoice an invoice_pdf URL, and for a lot of businesses that is enough. It stops being enough when you need your own layout, a second language, a tax field Stripe does not model, or a document that matches the rest of your paperwork.

The invoice_pdf file is not customisable beyond the branding settings in the dashboard. If you need more, you generate your own — which means mapping the Stripe invoice object onto your own template.

The amounts are integers in minor units

This is the first thing that catches people:

{
  "amount_due": 120000,
  "currency": "usd",
  "lines": { "data": [{ "amount": 120000, "quantity": 1 }] }
}

120000 is $1,200.00, not $120,000. Stripe stores minor units — cents for USD, and the same integer with no decimal shift for zero-decimal currencies like JPY, where 1200 means ¥1,200.

So the conversion is currency-dependent. Dividing everything by 100 is wrong for about a dozen currencies, and it is wrong in the direction that inflates a yen invoice by 100×.

Mapping the object

from decimal import Decimal

ZERO_DECIMAL = {"JPY", "KRW", "VND", "CLP"}   # abridged

def to_major(amount: int, currency: str) -> str:
    if currency.upper() in ZERO_DECIMAL:
        return str(amount)
    return str(Decimal(amount) / 100)

payload = {
    "document_type": "invoice",
    "data": {
        "invoice_number": inv["number"],
        "issue_date": date.fromtimestamp(inv["created"]).isoformat(),
        "currency": inv["currency"].upper(),
        "seller": {"name": "Acme Inc."},
        "buyer": {
            "name": inv["customer_name"],
            "email": inv["customer_email"],
        },
        "line_items": [
            {
                "name": line["description"],
                "quantity": str(line["quantity"] or 1),
                "unit_price": to_major(
                    line["amount"] // (line["quantity"] or 1), inv["currency"]
                ),
            }
            for line in inv["lines"]["data"]
        ],
    },
}

Note Decimal(amount) / 100 rather than amount / 100. The moment a monetary value passes through a float it can be a cent out, and on an invoice that is a number someone reconciles against a bank statement.

Render it

curl -X POST https://invoicepdfs.com/api/v1/documents/render \
  -H "Authorization: Bearer inv_..." \
  -H "Content-Type: application/json" \
  -d '{"document_type":"invoice","template":{"id":"tpl_modern"},
       "data": { ... },
       "output":{"format":"pdf","delivery":"url"}}'

You get a download URL back once the PDF exists.

Do it on the webhook, not on request

Generate when invoice.finalized fires, store the result, and serve the stored file afterwards. Two reasons: an invoice must not change after it is issued, and regenerating on every download means a template edit silently alters documents your customers already have.

What Stripe still owns

Keep Stripe as the source of truth for what was charged. The PDF is a presentation of that record, not a second copy of it — if the two ever disagree, the payment processor is right.


Same pattern applies to any billing system that exposes an invoice object. The Invoice PDF API takes the JSON and returns the document; the mapping above is the only part specific to Stripe.