Mapping QuickBooks invoice JSON to your own PDF
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.