Mapping QuickBooks invoice JSON to your own PDF

Anurag Jha ·

QuickBooks Online will render an invoice for you, in QuickBooks’ layout. If you need your own — a different language, your own template, a field Intuit does not print — you read the invoice object from the API and render it yourself.

The mapping is mostly mechanical. Three things are not.

The object

A trimmed QuickBooks invoice looks roughly like this:

{
  "DocNumber": "1043",
  "TxnDate": "2026-08-30",
  "CurrencyRef": { "value": "USD" },
  "CustomerRef": { "value": "58", "name": "Northstar LLC" },
  "Line": [
    {
      "DetailType": "SalesItemLineDetail",
      "Description": "API integration",
      "Amount": 1200.00,
      "SalesItemLineDetail": { "Qty": 1, "UnitPrice": 1200.00 }
    },
    { "DetailType": "SubTotalLineDetail", "Amount": 1200.00 }
  ],
  "TotalAmt": 1200.00
}

Filter the Line array first

Line is not a list of line items. It is a list of line types, and it includes computed rows — SubTotalLineDetail, DiscountLineDetail — mixed in with the real ones.

Copy it straight into your invoice payload and the subtotal appears as a line item, doubling the invoice.

items = [
    line for line in inv["Line"]
    if line.get("DetailType") == "SalesItemLineDetail"
]

Take the discount rows too, but map them to a discount, not a line:

discounts = [
    {"type": "fixed", "value": str(line["Amount"])}
    for line in inv["Line"]
    if line.get("DetailType") == "DiscountLineDetail"
]

Amounts are JSON numbers — convert immediately

Unlike Stripe, QuickBooks sends decimal numbers rather than integer minor units: 1200.00, not 120000. That is friendlier to read and worse to handle, because your JSON parser has already turned it into a float before your code sees it.

Convert at the boundary, via a string:

from decimal import Decimal
unit_price = str(Decimal(str(line["SalesItemLineDetail"]["UnitPrice"])))

Decimal(str(x)) rather than Decimal(x) — the latter faithfully preserves the float’s error instead of discarding it.

CustomerRef is a pointer, not a customer

CustomerRef.name is a display name. It is not the billing address, the tax registration, or the email — and a tax invoice usually needs at least the address.

Fetch the customer separately and cache it; do not build an invoice payload out of the reference alone.

customer = qb.get(f"/v3/company/{realm}/customer/{inv['CustomerRef']['value']}")
addr = customer["Customer"]["BillAddr"]

Putting it together

payload = {
    "document_type": "invoice",
    "data": {
        "invoice_number": inv["DocNumber"],
        "issue_date": inv["TxnDate"],
        "currency": inv["CurrencyRef"]["value"],
        "seller": {"name": "Acme Inc."},
        "buyer": {
            "name": customer["Customer"]["DisplayName"],
            "address": {
                "line1": addr.get("Line1", ""),
                "city": addr.get("City"),
                "postal_code": addr.get("PostalCode"),
                "country": addr.get("Country"),
            },
        },
        "line_items": [
            {
                "name": line.get("Description") or "Item",
                "quantity": str(line["SalesItemLineDetail"].get("Qty", 1)),
                "unit_price": str(
                    Decimal(str(line["SalesItemLineDetail"]["UnitPrice"]))
                ),
            }
            for line in items
        ],
        "discounts": discounts,
    },
}

Validate it before you render — it costs nothing and catches a missing quantity at write time rather than in front of a customer:

POST https://invoicepdfs.com/api/v1/documents/validate

The Invoice PDF API takes the payload above and returns the PDF. The filtering and the decimal handling are the parts you own — they are where the money goes wrong.