Lopifi APIs: Xero, QuickBooks Online, NetSuite
Three general ledgers, three different opinions about what an invoice is. This document compares them object by object, so a component built on one does not quietly break on another.
Everything in this system eventually reads from somebody else's ledger. The components render a normalised model; the integration layer produces it. The gap between those two is where most accounting products fail — not in the UI, but in the assumption that "invoice" means the same thing in all three systems. It does not.
The rule this document exists to enforce: normalise the shape, never the semantics. A field that exists in only one system must survive normalisation as an explicit, nullable, source-tagged field — never as a silently defaulted value.
Ordered by how much a difference costs you: model differences first, mechanics last.
1. The three APIs at a glance
| Xero | QuickBooks Online | NetSuite | |
|---|---|---|---|
| Style | REST-ish, one resource per noun | REST + SQL-like query language | Three overlapping APIs (REST record, SuiteQL, SOAP — removed in 2028.2) + RESTlets |
| Base | api.xero.com/api.xro/2.0/ |
quickbooks.api.intuit.com/v3/company/{realmId}/ |
{acct}.suitetalk.api.netsuite.com/services/rest/ |
| Payload | JSON or XML; arrays wrapped ({"Invoices":[…]}) |
JSON; single object or QueryResponse |
JSON; sublists nested or as sub-resources |
| Auth | OAuth 2.0 + Xero-Tenant-Id header |
OAuth 2.0 + realmId in path |
OAuth 2.0 (M2M cert or auth-code); OAuth 1.0 TBA for existing integrations only — no new ones from 2027.1 |
| Versioning | Path (2.0), rarely changes |
minorversion= query param, changes often and matters |
Dated WSDL / REST record schema per release (e.g. 2024.1) |
| Reads a list | Returns full records | Returns full records | REST returns ids only; SuiteQL returns rows |
| Server-side logic on write | None | Minimal (tax engine) | Extensive — SuiteScript, SuiteFlow, custom validation |
| Multi-entity | One org per tenant | One company per realm | Subsidiaries inside one account (OneWorld) |
| Custom dimensions | 2 tracking categories, fixed | Class + Department, fixed | Unlimited custom segments |
| Webhooks | Invoices, contacts, credit notes (+ app subscriptions), id-only payload | Many entities, id-only payload | None native — script it yourself |
The single most consequential row is the last-but-two. Xero and QBO give you a fixed, small dimensional model you can hardcode into a UI. NetSuite gives you a user-defined one, which means any NetSuite-aware component must render dimensions from metadata, not from a type literal.
The mental model of each
Xero is a document store. Nouns are flat and few. An invoice is an invoice; Type distinguishes ACCREC (sales) from ACCPAY (purchase). The GL is a derived, read-only artefact you fetch from a separate Journals endpoint — which, since 2 March 2026, only apps on the Advanced tier or above that have passed Xero's security assessment and had their use case approved may call. You cannot post arbitrary debits and credits except through ManualJournals.
QBO is a transaction engine with a query language. Nouns are many and specific — Invoice, Bill, Purchase, SalesReceipt, CreditMemo, VendorCredit, Deposit, Transfer are all separate types for what Xero models as two invoice types and a bank transaction. Lines are polymorphic via DetailType. Writes use optimistic locking (SyncToken) and are destructive by default unless you set sparse: true.
NetSuite is an ERP with a database exposed. Everything posting is a row in one transaction table with a type discriminator, plus sublists. Records carry customisations, approval workflows, and scripts that fire on write. The API is closer to "your write attempt enters a business process" than "your write updates a row".
2. Identity, tenancy, and the entity boundary
| Concept | Xero | QBO | NetSuite |
|---|---|---|---|
| Tenant unit | Organisation (tenantId, GUID) |
Company (realmId, numeric string) |
Account (accountId), containing many subsidiaries |
| Record id | GUID (InvoiceID) |
Numeric string (Id: "145"), unique per entity type only |
Numeric string (id: "8412"), unique per record type |
| Human number | InvoiceNumber, ContactNumber |
DocNumber |
tranid |
| Concurrency control | Last write wins | SyncToken — must round-trip, mismatch = 400 |
Optimistic via record retrieval; scripts may reject |
| Consolidation | Not in product — one org, one currency base | Not in product | Native: subsidiary hierarchy, elimination, consolidated FX rates |
Two hard consequences.
QBO ids are not globally unique. Invoice 145 and Bill 145 both exist. Any cache, URL, or component key must be {realmId}:{entityType}:{id}. This bites the first time you build a global search.
Multi-entity is a modelling difference, not a feature difference. A Xero or QBO group is n disconnected tenants that you must consolidate yourself: your system owns the entity tree, the intercompany elimination, and the FX translation. A NetSuite OneWorld account already has all three, and every record carries a subsidiary field that must be respected on read and on write. See multi-entity and FX for what the UI has to do about it.
Practical rule: model the entity tree in your own schema regardless of source, and treat NetSuite's subsidiary as a mapping into it rather than as the tree itself. Otherwise a mixed-source group (two Xero orgs plus a NetSuite subsidiary — common in mid-market) has no coherent parent.
3. Chart of accounts
The one object all three broadly agree on, and still the one that most often breaks the P&L.
| Field | Xero Account |
QBO Account |
NetSuite account |
|---|---|---|---|
| Id | AccountID (GUID) |
Id |
id |
| Number | Code — string, unique-enforced, required except on bank accounts, editable |
AcctNum — optional; absent unless account numbers enabled |
acctnumber |
| Name | Name |
Name, plus FullyQualifiedName ("Parent:Child") |
acctname, plus displayname |
| Type | Type (~30 values: BANK, CURRENT, FIXED, REVENUE, DIRECTCOSTS, EXPENSE, EQUITY…) |
AccountType (~20) + AccountSubType (~80) |
accttype (Bank, AcctRec, AcctPay, Income, COGS, Expense, OthCurrAsset, FixedAsset…) |
| Class | Class: ASSET/LIABILITY/EQUITY/REVENUE/EXPENSE |
Classification: same five |
Derived from accttype |
| Hierarchy | None — flat list | SubAccount + ParentRef, up to 5 levels |
parent — arbitrary depth |
| Reserved accounts | SystemAccount (DEBTORS, CREDITORS, BANKCURRENCYGAIN, RETAINEDEARNINGS…) |
Implied by AccountType (Accounts Receivable, Accounts Payable) |
AcctRec / AcctPay types; specialaccounttype |
| Bank detail | BankAccountNumber, CurrencyCode |
AcctNum, CurrencyRef |
currency, bank fields on account |
| Balance | Not on the object | CurrentBalance, CurrentBalanceWithSubAccounts |
Not on the record (query the GL) |
| Multi-entity | — | — | subsidiary list — an account can be restricted per subsidiary |
| Disable | Status: ACTIVE/ARCHIVED |
Active: true/false |
isinactive |
What actually differs
Hierarchy. Xero has none. QBO has five levels and encodes the path in FullyQualifiedName with : as a separator — which means a colon in an account name is a parsing hazard. NetSuite is arbitrary depth. A COA tree component must therefore handle a flat list gracefully; don't build one that assumes a parent link exists.
Account numbers are not reliable identifiers. They are optional in QBO (AcctNum is only populated when the preference is on) and on Xero bank accounts — Xero enforces Code as unique and requires it on every other account — and editable in all three. Unique is not stable: a renumbered chart breaks every mapping keyed on the code. Key on the source id; display the number.
Type granularity. All three separate receivables from other current assets at the type level — QBO AccountType has Accounts Receivable vs Other Current Asset, NetSuite has AcctRec vs OthCurrAsset. QBO's AccountSubType (~80 values) gives the finest granularity of the three, below that. Xero's Type is coarser but includes SystemAccount for the accounts you must never let a user post to. Mapping all three onto one taxonomy loses information in exactly one direction: map to the coarsest common type, and keep the source type verbatim alongside it. Statement grouping should read your own type, tooltips should show theirs.
Normal balance is not a field anywhere. Derive it from class — asset and expense are debit-normal, liability, equity and revenue are credit-normal — then invert it for contra accounts: accumulated depreciation, allowance for doubtful accounts, treasury stock, sales returns and discounts. Take the contra flag from the source subtype (QBO AccumulatedDepreciation, DiscountsRefundsGiven…) or from your own mapping; never from class alone. See the accounting model §2.
4. Parties: contacts, customers, vendors
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Model | One Contact for both roles |
Separate Customer and Vendor |
Separate customer and vendor records |
| Role flag | IsCustomer / IsSupplier — read-only, derived from transaction history |
Implicit in the entity type | Implicit; entity is the shared abstraction |
| Uniqueness | Name must be unique across the org |
DisplayName must be unique across Customer, Vendor, Employee (and Other Name) — a customer and a vendor cannot both be "Acme Ltd" |
entityid unique |
| Hierarchy | ContactGroups (flat grouping, not parenting) |
Job / sub-customer via ParentRef, BillWithParent |
parent on customer; true hierarchy |
| Addresses | Addresses[] typed STREET / POBOX |
BillAddr, ShipAddr, OtherAddr as distinct fields |
addressbook sublist, many addresses, one default each |
| Contacts-of-contact | ContactPersons[] |
Single name/email fields | contact records linked to entity |
| Defaults | DefaultCurrency, SalesDefaultAccountCode, PurchasesDefaultAccountCode, PaymentTerms |
CurrencyRef, SalesTermRef, PaymentMethodRef |
currency, terms, receivablesaccount |
| Tax id | TaxNumber |
PrimaryTaxIdentifier / TaxIdentifier |
vatregnumber, taxidnum |
| 1099 | — | Vendor1099 boolean |
is1099eligible |
| Balance | — | Balance, BalanceWithJobs on Customer |
Query |
The unification trap
Xero's single-contact model is genuinely different, not cosmetically different. One Contact record can be simultaneously a customer and a supplier — and often is, legitimately (a client who is also a subcontractor). QBO forces you to create two records, with no link between them, so the same real-world party appears twice with different ids and independent balances.
If your canonical model has a single Party with role flags — the Xero shape — then QBO import must decide whether to merge Customer "Acme Ltd" and Vendor "Acme Ltd (V)" into one party. QBO will not let both be called "Acme Ltd" — DisplayName is unique across customers, vendors and employees — so the same party usually arrives with a suffix on one side. Do not auto-merge on name, exact or fuzzy. Stripping (V) finds candidates, not matches, and wrong-merging corrupts both aging reports. Merge only on an explicit user action or a strong identifier (tax id), and keep the two source ids on the merged party.
If your canonical model has separate customers and suppliers — the QBO shape — then a Xero contact that is both must be projected into two, and any UI that navigates "from party to all transactions" has to reunite them.
Pick one, write it down, and make the roundtrip explicit. This is a decision, not a detail.
5. Sales invoices (AR)
Xero Invoice (Type: ACCREC) |
QBO Invoice |
NetSuite invoice |
|
|---|---|---|---|
| Discriminator | Type field on a shared object |
Its own entity | type: invoice on transaction |
| Number | InvoiceNumber (unique, may be auto) |
DocNumber (uniqueness is a preference) |
tranid |
| Party | Contact |
CustomerRef |
entity |
| Dates | Date, DueDate |
TxnDate, DueDate |
trandate, duedate |
| Terms | Contact.PaymentTerms (no per-invoice term object) |
SalesTermRef |
terms |
| Status | DRAFT, SUBMITTED, AUTHORISED, PAID, VOIDED, DELETED |
No status field — derive from Balance, TotalAmt, void state |
status (Open, Paid In Full, Pending Approval…), approvalstatus |
| Open amount | AmountDue |
Balance |
amountremaining |
| Total | Total, SubTotal, TotalTax |
TotalAmt, TxnTaxDetail.TotalTax |
total, taxtotal |
| Tax mode | LineAmountTypes: Exclusive/Inclusive/NoTax |
GlobalTaxCalculation: TaxExcluded/TaxInclusive/NotApplicable (non-US) |
Per-line tax code; nexus-driven |
| Line target | Account code, or item | ItemRef effectively required |
Item sublist (item required) or expense sublist |
| Line types | Uniform | DetailType: SalesItemLineDetail, GroupLineDetail, DescriptionOnly, DiscountLine, SubTotalLine |
Sublist columns |
| Dimensions | Tracking[] per line (≤2) |
ClassRef (line or header), DepartmentRef (header) |
department / class / location / custom segments, header and line |
| FX | CurrencyCode, CurrencyRate |
CurrencyRef, ExchangeRate |
currency, exchangerate |
| Links | Payments[], CreditNotes[], Prepayments[], Overpayments[] inline |
LinkedTxn[] (payments, credit memos) |
links / transaction relationships |
| Attachments | HasAttachments, sub-resource |
Attachable entity, queried separately |
file records via sublist |
The three differences that actually cost you
1. QBO has no invoice status. There is no Status field. "Open / Paid / Overdue / Voided" is something you compute:
// QBO status is derived, never read.
function qboInvoiceStatus(inv: QboInvoice, today: Date): InvoiceStatus {
if (inv.PrivateNote?.startsWith("Voided")) return "voided"; // voided invoices keep TotalAmt 0
if (inv.TotalAmt === 0 && inv.Balance === 0) return "voided_or_zero";
if (inv.Balance === 0) return "paid";
if (inv.Balance < inv.TotalAmt) return "part_paid";
if (inv.DueDate && inv.DueDate < today) return "overdue";
return "open";
}
A void in QBO zeroes the amounts and keeps the record — so a naive "sum of TotalAmt" revenue figure silently loses voided invoices, correctly, but a naive "count of invoices" over-counts. Xero, by contrast, gives you VOIDED explicitly and preserves the original amounts on the record.
2. QBO wants items; Xero wants accounts. A Xero invoice line can post straight to AccountCode: "200". A QBO invoice line posts to an Item, and the item carries IncomeAccountRef. The GL result is the same; the write path is not. To create an invoice in QBO from a source that has only an account, you must resolve or create a service item pointing at that account — and that item then appears in the customer's product list forever. Plan for a dedicated "integration" item set rather than creating one item per account on the fly.
3. Dimension placement. Xero puts tracking on the line, always. QBO puts DepartmentRef on the header and ClassRef on the line or header depending on a company preference. NetSuite allows both, with line overriding header. A dimensional P&L component must therefore accept per-line dimensions and know that some sources will hand it a header value applied to every line. Fan the header value down to the lines at normalisation time and record that you did — otherwise an edit UI will happily write per-line values back to a system that cannot store them.
Same fact, three payloads
Selling one consulting day at 1,000, 20% VAT exclusive, tracked to department "R&D":
// Xero — account-based line, tracking on the line
{ "Type": "ACCREC", "Contact": { "ContactID": "…" },
"Date": "2026-09-07", "DueDate": "2026-10-07",
"LineAmountTypes": "Exclusive",
"LineItems": [{
"Description": "Consulting", "Quantity": 1, "UnitAmount": 1000,
"AccountCode": "200", "TaxType": "OUTPUT2",
"Tracking": [{ "Name": "Department", "Option": "R&D" }]
}] }
// QBO (non-US company) — item-based line, DetailType discriminator,
// department on the header; TaxCodeRef is the company's numeric 20% code id
{ "CustomerRef": { "value": "58" },
"TxnDate": "2026-09-07", "DueDate": "2026-10-07",
"GlobalTaxCalculation": "TaxExcluded",
"DepartmentRef": { "value": "3" },
"Line": [{
"DetailType": "SalesItemLineDetail", "Amount": 1000,
"SalesItemLineDetail": {
"ItemRef": { "value": "12" }, "Qty": 1, "UnitPrice": 1000,
"TaxCodeRef": { "value": "8" }
}
}] }
// NetSuite — item sublist, segments per line, subsidiary on the header
{ "entity": { "id": "58" }, "trandate": "2026-09-07", "duedate": "2026-10-07",
"subsidiary": { "id": "2" },
"item": { "items": [{
"item": { "id": "12" }, "quantity": 1, "rate": 1000,
"taxcode": { "id": "5" }, "department": { "id": "7" },
"cseg_product": { "id": "3" }
}] } }
Three payloads, one journal: Dr AR 1,200 / Cr Revenue 1,000 / Cr VAT 200. The normalised model should be the journal-adjacent one; the payload differences belong entirely in the adapter.
6. Bills (AP)
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Object | Invoice with Type: ACCPAY |
Bill (accrual) — and Purchase (cash/card, no AP) |
vendorbill |
| Line to account | AccountCode on line |
AccountBasedExpenseLineDetail |
expense sublist |
| Line to item | ItemCode on line |
ItemBasedExpenseLineDetail |
item sublist |
| AP control account | Implicit CREDITORS system account |
APAccountRef — selectable |
account (AcctPay) |
| Vendor ref | Contact |
VendorRef |
entity |
| Billable to customer | LinkedTransactions — links an ACCPAY/SPEND line to a customer or ACCREC invoice (the billable-expenses API) |
BillableStatus, CustomerRef on line |
isbillable, customer on line |
| PO match | Not exposed — only PurchaseOrder.Status: BILLED |
LinkedTxn to PurchaseOrder |
Native 2- and 3-way match, receipt records |
| Approval | Status SUBMITTED (single soft gate) |
None in the API | approvalstatus, SuiteFlow multi-step |
| Open amount | AmountDue |
Balance |
amountremaining |
The differences
Xero models a bill as an invoice. Same endpoint, same object, Type flips the sign of the GL impact and swaps the control account. This is elegant and it is a trap for a component that renders "invoice" — an AR aging table fed from /Invoices without a Type filter will show your payables as receivables. Always filter on Type, never assume the endpoint.
QBO splits accrual from cash. A vendor bill you will pay later is a Bill. A card swipe or immediate payment is a Purchase (with PaymentType: Cash | Check | CreditCard) which posts no AP at all. Any "total spend with this vendor" figure that reads only Bill under-reports, often dramatically, because card spend lives in Purchase. Xero puts the equivalent in BankTransactions (Type: SPEND); NetSuite has vendorbill versus check / expensereport / card charge.
This is the single most common source of wrong vendor-spend numbers. See procure to pay — the spend dashboard must aggregate across bill and non-bill spend objects, per source.
NetSuite's two sublists are not interchangeable. A vendor bill carries an expense sublist (account, amount, memo, segments) and an item sublist (item, quantity, rate, receipt matching). Item lines participate in three-way match and inventory; expense lines do not. Normalising both into one flat lines[] loses the distinction, which matters the moment someone asks why a bill won't match its receipt. Keep a lineKind: "account" | "item" discriminator in the canonical model — QBO needs it too, for its two DetailTypes.
Approval is real in NetSuite only. Xero's SUBMITTED status is a flag, not a workflow. QBO's API has no approval concept. NetSuite has multi-step approval routing that your write may enter and not clear. A payables approval component that assumes it can approve via the API works on NetSuite and is decorative elsewhere — where the approval state must live in your system, not the ledger's.
7. Payments and allocation
| Xero | QBO | NetSuite | |
|---|---|---|---|
| AR payment | Payment (also BatchPayment for grouping) |
Payment |
customerpayment |
| AP payment | Same Payment object |
BillPayment — separate entity |
vendorpayment |
| Allocation model | One payment record per invoice — but PUT /Payments takes an array and BatchPayments settles many invoices in one call |
One payment, Line[] with LinkedTxn per invoice |
One payment, apply sublist per invoice |
| Partial payment | Amount < AmountDue; multiple payment records |
Line.Amount per linked txn |
apply.amount per line |
| Unapplied cash | Overpayment / Prepayment objects |
UnappliedAmt on Payment |
Unapplied on payment; customerdeposit |
| Bank account | Account (must have EnablePaymentsToAccount) |
DepositToAccountRef |
account |
| Payment method | Reference free text |
PaymentMethodRef |
paymentmethod |
| FX on settlement | CurrencyRate on payment; Xero computes realised gain |
ExchangeRate; QBO computes |
exchangerate; NetSuite computes, per book |
The allocation shapes are genuinely incompatible. Xero: N payment records, each pointing at one invoice. QBO and NetSuite: one payment record with N allocation lines. A canonical Payment { lines: Allocation[] } handles all three — Xero simply produces one-line payments, which you may group by BatchPayment id when present. Going the other way (canonical to Xero) means splitting one user action into N payment records. They can travel in one call — an array to PUT /Payments, or one BatchPayments — but with summarizeErrors=false each item succeeds or fails on its own: check every allocation in the response, make the write replayable, and never show the user "payment recorded" until all N succeed.
Unapplied cash has three names. Xero splits it into Overpayment (customer paid too much on an invoice) and Prepayment (customer paid before an invoice existed) — separate objects with their own endpoints, which many integrations forget entirely, then wonder why AR doesn't tie. QBO keeps it as UnappliedAmt on the payment; on the accrual basis that credit sits in Accounts Receivable, and the "Unapplied Cash Payment Income/Expense" lines appear only on cash-basis reports. NetSuite leaves it unapplied on the payment record and additionally has customerdeposit.
An AR aging component that only sums invoice balances will disagree with the ledger's own aging report by exactly the unapplied cash. Fetch it, show it as its own row, and tie. See order to cash.
8. Credit notes, refunds, and the negative document
| Purpose | Xero | QBO | NetSuite |
|---|---|---|---|
| Reduce a customer invoice | CreditNote (Type: ACCRECCREDIT) |
CreditMemo |
creditmemo |
| Reduce a vendor bill | CreditNote (Type: ACCPAYCREDIT) |
VendorCredit |
vendorcredit |
| Cash back to customer | Allocate credit to Payment, or Refund on the credit note |
RefundReceipt |
customerrefund |
| Cash back from vendor | Payment against ACCPAYCREDIT |
Deposit linked to VendorCredit |
vendorpayment / deposit |
| Cash sale (no AR) | BankTransaction Type: RECEIVE |
SalesReceipt |
cashsale |
Two rules that hold across all three:
Never render a credit note as a negative invoice. It is a distinct document with its own number sequence, and finance teams reconcile them separately. A ledger table that merges them into one signed column destroys the audit path. See the accounting model §1.
Amounts are stored positive. All three store credit notes with positive amounts and encode the direction in the document type. If your normalisation flips signs, do it in exactly one place and test the round trip — a double-negation bug here reads as a plausible number and survives review.
9. Journals and the general ledger
This is where the three diverge most and where reconciliation lives.
| Xero | QBO | NetSuite | |
|---|---|---|---|
| User-entered JE | ManualJournals |
JournalEntry |
journalentry |
| Full GL feed | Journals — read-only, every posting from every source. Since 2 Mar 2026: Advanced tier or above, security assessment and use-case approval |
No equivalent — no GL export endpoint | transaction + transactionline via SuiteQL |
| Debit/credit shape | Journals: NetAmount signed (+ = debit). ManualJournals: LineAmount signed |
JournalEntryLineDetail.PostingType: "Debit" | "Credit" + positive Amount |
debit and credit columns on the line |
| Pagination | offset={JournalNumber}, 100 per page, sequential |
n/a | limit/offset on SuiteQL |
| Entity on a line | Contact not available on Journals lines |
Entity ref (customer/vendor/employee) per line |
entity per line |
| Dimensions | TrackingCategories[] per line |
ClassRef, DepartmentRef per line |
full segment set per line |
| Multi-book | — | — | accountingbook — the same txn posts differently per book |
| Reversal | Create the opposite manual journal | JournalEntry with Adjustment flag; reversal is manual |
reversaldate on the record — native auto-reversal |
Xero's Journals endpoint is the crown jewel and is easy to miss. It emits the actual GL — every posting from invoices, payments, bills, bank transactions, manual journals — as immutable, sequentially-numbered journal records. Paginate by offset on JournalNumber and you get an append-only, gapless, perfectly replayable ledger feed. If you are building anything that reconciles or re-derives statements, this is the endpoint you build on, not /Invoices — if you can get it. Since Xero's app tiers took effect on 2 March 2026, Journals is a premium endpoint: the app must be on the Advanced tier or above, pass Xero's security assessment (initial and annual) and have its use case approved; a Starter-tier app gets a 401. ManualJournals stays open on every tier, but it only carries user-entered journals, not the postings behind invoices, bills and payments. Without Journals access, Xero falls back to the same options as QBO below: the Reports API, or reconstruction from the document endpoints. Check the Xero developer pricing page before designing on it.
QBO has nothing equivalent, and this is the hardest structural gap in the three. There is no endpoint that returns GL postings. Your options, in order of practicality:
- Reports API —
GeneralLedger,TrialBalance,ProfitAndLossreturn report rows, not records: nested, presentation-shaped, with header/summary rows to walk. Correct totals, awkward parsing, no stable row ids. - Reconstruct from every transaction entity plus knowledge of QBO's posting rules. Full control, and you now own the correctness of QBO's tax and FX posting logic — which is a large, ongoing liability.
/cdc(change data capture) to keep entity mirrors fresh, and derive statements from the mirror.
Most teams end up with (1) for tie-out and (3) for detail. Whichever you choose, display which one produced the number — a P&L reconstructed from entities and a P&L from the Reports API will occasionally differ, and the user needs to know which they are looking at.
NetSuite gives you SQL. transactionline joined to transaction and accountingtransaction (or transactionaccountingline for the debit/credit amounts by book) is the GL. It is the most powerful of the three and the most demanding: you must know which line is the posting line, filter posting = 'T', and pick the right accounting book.
-- NetSuite: GL lines for a period, primary book
SELECT t.trandate, t.tranid, t.type, a.acctnumber, a.acctname,
tal.debit, tal.credit, tl.department, tl.class, tl.location, tl.memo
FROM transaction t
JOIN transactionline tl ON tl.transaction = t.id
JOIN transactionaccountingline tal ON tal.transaction = t.id
AND tal.transactionline = tl.id
JOIN account a ON a.id = tal.account
WHERE tal.posting = 'T'
AND tal.accountingbook = 1
AND t.trandate BETWEEN TO_DATE('2026-08-01','YYYY-MM-DD')
AND TO_DATE('2026-08-31','YYYY-MM-DD')
Note the shape difference that survives into your model: Xero hands you one signed NetAmount, QBO a PostingType enum, NetSuite two columns. Store two columns. Converting signed to two columns is lossless; the reverse is not, once rounding and zero-amount lines are involved.
10. Items, products, and services
Xero Item |
QBO Item |
NetSuite item records | |
|---|---|---|---|
| Types | One type; IsTrackedAsInventory flag |
Type: Inventory, NonInventory, Service, Group (a bundle in the UI), Category |
Many record types: inventoryitem, noninventoryitem, serviceitem, assemblyitem, kititem, itemgroup, giftcertificateitem… |
| Code | Code (unique) |
Name / Sku |
itemid |
| Sales side | SalesDetails.{UnitPrice, AccountCode, TaxType} |
IncomeAccountRef, UnitPrice |
incomeaccount, pricing sublists |
| Purchase side | PurchaseDetails.{UnitPrice, COGSAccountCode} |
ExpenseAccountRef, PurchaseCost |
expenseaccount / cogsaccount |
| Inventory | InventoryAssetAccountCode, QuantityOnHand |
AssetAccountRef, QtyOnHand, InvStartDate |
Full: locations, bins, lots, serials, costing methods |
| Hierarchy | None | ParentRef via Category items |
parent, matrix items |
Xero treats items as an optional convenience — most Xero invoices have no item at all. QBO treats them as the required mechanism for sales lines. NetSuite treats them as a whole subsystem with its own costing engine.
For a components library the implication is narrow but real: an invoice line editor cannot require an item, and cannot forbid one. Item is nullable, and whether it is required is a property of the connected source, delivered as capability metadata — not a prop hardcoded per instance.
11. Tax
The messiest area, and the one where "just normalise it" fails hardest.
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Object | TaxRate (with TaxComponents[] for compound rates) |
TaxCode → TaxRate (many-to-many via TaxRateDetail) |
taxcode, taxgroup, taxtype, nexus |
| Line reference | TaxType (the rate's code, e.g. OUTPUT2) |
TaxCodeRef |
taxcode |
| Inclusive/exclusive | LineAmountTypes on the document |
GlobalTaxCalculation on the document (non-US only) |
Per-nexus configuration |
| Can you create rates? | Yes | US automated sales tax: no — rates are Intuit-managed. Non-US: yes | Yes |
| Computed tax | TotalTax, per-line TaxAmount |
TxnTaxDetail.{TotalTax, TaxLine[]} |
taxtotal, per-line |
| Overriding computed tax | Per-line TaxAmount override allowed |
TxnTaxDetail override, honoured inconsistently under AST |
Line-level override |
| Jurisdictions | Flat rate list | AST resolves by address | Nexus model — jurisdiction by subsidiary and address |
Three things to internalise:
QBO US is a different product from QBO non-US. Under Automated Sales Tax you cannot create tax rates via the API, and the tax on a transaction is computed by Intuit from the addresses. Sending your own tax amounts may be silently overridden. Any tax UI must therefore be read-mostly for US QBO and editable elsewhere — again, capability metadata, not a hardcoded branch.
Compound and multi-component tax survives only as a list. Xero's TaxComponents[] and QBO's TaxLine[] both express "this 13% is 5% federal plus 8% provincial". A canonical taxAmount: Money scalar throws that away, and it is exactly what a tax return needs. Model tax as { code, name, components: [{name, rate, amount}], total }.
Never re-derive tax from rate × base. Rounding rules differ per system and per jurisdiction (per-line versus per-document, half-up versus banker's). Store the tax amount the source computed, and if you display a rate, display it as the source's rate. See numerics.
12. Dimensions: tracking, class, department, segment
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Mechanism | TrackingCategory with TrackingOption[] |
Class, Department entities |
department, class, location, subsidiary, custom segments |
| How many | Maximum 2 active | 2 (Class + Department), gated by preference | 4 built-in + unlimited custom |
| Naming | User-named categories ("Region", "Team") | Fixed names; UI relabels Department as "Location" | User-named custom segments |
| Hierarchy | Flat options | SubClass / ParentRef |
Hierarchical |
| Where applied | Invoice/bill/journal lines | Class on line or header (preference); Department on header | Header and line, line wins |
| Constraint | — | — | Segments can be required, filtered by subsidiary, and validated by script |
| API surface | /TrackingCategories |
/query?query=select * from Class |
customsegment metadata + cseg_* fields on records |
This table is the reason a dimensional filter bar cannot be a static component. Xero gives you at most two, named by the user. NetSuite gives you an arbitrary set whose names and field keys are discovered at runtime (cseg_product, cseg_channel, …). Any component that filters or groups by dimension must take:
type Dimension = {
key: string; // "tracking.0" | "class" | "department" | "cseg_product"
label: string; // the user's name for it, from the source
options: DimensionOption[]; // may be hierarchical
appliesTo: "line" | "header" | "both";
required: boolean;
};
…as data, and render n of them. Hardcoding "Class" and "Department" columns produces a component that is wrong on two of the three systems.
Note also that Xero's two-category limit is a hard product limit, not a plan limit. A client who wants department and project and location on Xero cannot have it, and the honest UI answer is to say so rather than to offer a control that cannot be saved.
13. Periods, locking, and the close
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Period object | None — an org has a PeriodLockDate and EndOfYearLockDate |
None — Preferences.AccountingInfoPrefs.BookCloseDate |
accountingperiod record — first class, per year/quarter/month |
| Granularity | Two org-wide dates: PeriodLockDate blocks everyone but advisers, EndOfYearLockDate blocks everyone |
One close date, optionally password-protected | Per period, per subsidiary, and separately for AP/AR/GL |
| Enforced on API writes? | Depends on the connected user's role — before EndOfYearLockDate always fails; before PeriodLockDate fails unless that user is an adviser |
Partially — close date is advisory for some paths | Yes, strictly, per book and per subsidiary |
| Reopen | Move the date | Move the date | Explicit period reopen, audited |
| Adjusting entries | Ordinary manual journals | JournalEntry.Adjustment: true |
Period-specific adjustment flag, isadjust |
NetSuite is the only one with a real period model. Xero and QBO have dates — two in Xero, whose effect on an API write depends on the connected user's role — which means "period" in your system is your own construct that you must reconcile against a single cut-off. See periods and close — the close checklist component should treat period state as owned by your system, with the ledger's lock date as one input signal rather than the source of truth.
Practical consequence for write paths: always check the lock date before offering an edit. A disabled control with "Period locked 31 Aug" is correct UI; a 400 from Xero surfaced as a red toast after the user typed for a minute is not.
14. Attachments, memos, and the audit trail
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Attachment model | Attachments sub-resource on most endpoints |
Attachable entity with AttachableRef[] pointing back at txns |
file record in the file cabinet, linked via sublist |
| Upload | PUT/POST /{Endpoint}/{Guid}/Attachments/{FileName}, raw body |
Multipart /upload |
file record with base64 contents, then attach |
| Discovery | HasAttachments flag on the record |
Query Attachable by AttachableRef |
Sublist on the record |
| Internal notes | HistoryAndNotes endpoint |
PrivateNote field |
memo, usernotes, system notes |
| Change history | HistoryAndNotes (read + append note) |
None via API | systemnote — full field-level audit, queryable |
| Who/when | UpdatedDateUTC |
MetaData.{CreateTime, LastUpdatedTime} — no user |
lastmodifiedby, owner, systemnote per field |
Only NetSuite gives you a real audit trail through the API. Xero gives you a history feed per record; QBO gives you timestamps and no actor at all. Any audit or evidence component (see controls and audit) must therefore build its own trail over your actions and be explicit that ledger-side changes made by a human in the ledger's own UI may be invisible to you — on QBO, they are.
Attachment retrieval is the most under-estimated cost in a ledger integration: QBO requires a second query per transaction unless you batch, and files are the largest thing you will sync.
15. Reports
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Endpoint | /Reports/{ProfitAndLoss,BalanceSheet,TrialBalance,AgedReceivablesByContact,BankSummary,…} |
/reports/{ProfitAndLoss,BalanceSheet,GeneralLedger,TrialBalance,AgedReceivables,…} |
Saved searches, SuiteAnalytics, SuiteQL |
| Shape | Rows[] with RowType: Header | Section | Row | SummaryRow |
Rows / Columns nested, ColData[] |
Rows you define |
| Stable ids | Some rows carry AccountID in cells |
ColData.id sometimes present |
You control the query |
| Comparatives | periods, timeframe params |
date_macro, summarize_column_by |
Query it |
| Drill-down | Not linked | Not linked | Native |
Report endpoints are presentation output, not data. They are the right tool for tie-out — proving your reconstructed P&L equals the ledger's own — and the wrong tool for populating an interactive statement component, because the rows have no reliable identity to drill from.
The pattern that works: build statements from GL data (or entity mirrors), then run the report endpoint on a schedule and assert equality. Surface a variance if they disagree. That check is worth more to a controller than any chart on the page. See statement semantics.
16. Mechanics: reading, writing, limits
Reading and pagination
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Page mechanism | page + pageSize (newer endpoints); Journals uses offset |
STARTPOSITION + MAXRESULTS in the query string |
limit + offset, hasMore / links |
| Page size | 100 default, up to 1000 on endpoints that support pageSize |
Up to 1000 rows | Up to 1000 |
| Filtering | where= expression, order=; string ops are limited |
SQL-like WHERE, ORDER BY, no JOIN, no aggregate functions |
SuiteQL: near-full SQL incl. joins and aggregates |
| List returns | Full records | Full records | REST: ids + links only. Use SuiteQL for rows |
| Delta sync | If-Modified-Since header (UTC) |
WHERE MetaData.LastUpdatedTime > '…', or /cdc |
lastmodifieddate in SuiteQL |
| Deletes visible? | Status becomes DELETED/VOIDED — still returned |
/cdc reports deletes; queries do not |
Hard deletes are invisible — reconcile by id set, or use deletedrecord |
The NetSuite REST list behaviour deserves emphasis: GET /record/v1/invoice returns a page of {id, links} objects, not invoices. Fetching 5,000 invoices that way is 5,001 calls against a concurrency limit in the single digits. Use SuiteQL for bulk reads. This is not an optimisation; it is the difference between a sync that finishes and one that does not.
Deletion visibility is the quiet data-integrity issue. Xero never truly deletes — it flips status, so a delta sync sees it. QBO surfaces deletes only through /cdc (with a limited lookback window), so a poll-only integration accumulates ghost records. NetSuite hard-deletes, and a deleted record simply stops appearing. Any full-refresh strategy must reconcile the id set, not just apply changes.
Writing
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Update semantics | POST the object; supplied fields update, arrays replace wholesale | Full update replaces the entity unless "sparse": true |
PATCH updates supplied fields |
| Concurrency | Last write wins | SyncToken must match, else 400 |
Record-level; scripts may reject |
| Idempotency | Idempotency-Key header (supported on write endpoints) |
requestid query parameter — unique per company, up to 50 characters (36 in a batch); a retry with the same id returns the original response instead of creating a duplicate |
None — dedupe yourself |
| Batch | Arrays of up to 50–60 objects per POST on most endpoints | /batch, up to 30 operations |
Not on REST record; SuiteScript for bulk |
| Partial failure | SummarizeErrors=false returns per-object validation results |
Per-operation results in the batch response | Per-call |
| Delete | Status change (VOIDED, DELETED) |
POST ?operation=delete or ?operation=void |
DELETE (real) where permitted |
QBO's full-replace update is the most dangerous single behaviour in these three APIs. POST an Invoice with only the fields you wanted to change and you will delete every line on it. Read the current record, mutate, write back the whole thing with the current SyncToken — or set sparse: true and accept its narrower semantics. Never construct a QBO update payload from scratch.
Rate limits and concurrency
Approximate, and the first thing vendors change — verify against current documentation before relying on a number.
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Per tenant/company | ~60 calls/minute, ~5,000/day (1,000/day on the Starter tier since 2 Mar 2026) | ~500 requests/minute per realm; batch: 40/min per realm and app | Governed by concurrency, not rate — base 5 (Standard), 15 (Premium), 20 (Enterprise/Ultimate) concurrent per account, plus 10 per SuiteCloud Plus licence |
| Per app | ~10,000 calls/minute across tenants | Limits are per realm and app — no separate app-wide cap published | — |
| Concurrency | ~5 concurrent per tenant | ~10 concurrent per realm and app | Shared across SOAP, REST and RESTlets; RESTlets additionally consume SuiteScript usage units |
| On breach | 429 + Retry-After |
429 (ThrottleExceeded) — wait 60 seconds |
429 / SSS_REQUEST_LIMIT_EXCEEDED |
The shapes differ enough to change your architecture. Xero's daily cap means a full history backfill of a large org is a multi-day job that must be resumable. NetSuite's concurrency governance means parallelism is capped hard and queueing must be centralised per account — two workers each "politely" using three connections will trip a five-connection limit. QBO is the most forgiving of the three.
Build one token-bucket limiter per (source, tenant) and make every call pass through it, including retries. Retry 429 and 5xx with exponential backoff and jitter; never retry a 400.
Webhooks
| Xero | QBO | NetSuite | |
|---|---|---|---|
| Available | Invoices, Contacts, Credit notes (added March 2026), app Subscriptions | Most entities | None |
| Payload | Ids + event type, no data | Ids + operation, no data | — |
| Signature | HMAC-SHA256, x-xero-signature |
HMAC-SHA256, intuit-signature |
— |
| Validation | Must return 2xx within seconds or the endpoint is disabled ("intent to receive") | Retries with backoff | — |
Both are notifications, not data feeds: you receive "invoice X changed" and must fetch. Both require the signature check before you trust anything — and Xero will disable an endpoint that fails its validation handshake, so the verification path has to work before any business logic does.
NetSuite has no webhooks. The substitutes are a User Event SuiteScript that calls out on save, or scheduled polling on lastmodifieddate. Most integrations poll, which makes freshness a per-source property your UI should be honest about: "Synced 4 minutes ago" is a real component, not decoration.
Dates, numbers, currency
- Dates. Xero JSON returns legacy
.NETepoch strings ("/Date(1749254400000+0000)/") on some endpoints alongside ISO-8601 on others — parse both. QBO uses ISO-8601 dates for transactions and full timestamps inMetaData. NetSuite dates are rendered in the account's or user's timezone and preferred format, which is the classic source of off-by-one-day errors at period boundaries. Normalise every date to a plain calendar date (no time, no zone) for transaction dates, and to UTC instants for audit timestamps. See date handling in numerics. - Money. All three send JSON numbers. Parse to a decimal type immediately — never let a float touch a monetary value — and carry the currency code with it.
- FX. Each transaction carries its own rate and each system computes realised/unrealised gains its own way. Do not recompute the base-currency amount from
amount × rate; store the source's base amount when it provides one. NetSuite additionally has per-book and consolidated rates, so "the" base amount is not a single number.
17. The canonical model
The model these components render. Every adapter produces this; nothing above this line appears in a component prop.
type SourceSystem = "xero" | "qbo" | "netsuite";
/** Every normalised record keeps its provenance. Non-negotiable. */
type SourceRef = {
system: SourceSystem;
tenantId: string; // Xero tenantId | QBO realmId | NetSuite accountId
entityType: string; // the source's own noun, verbatim
id: string;
/** The untouched source payload, for the "why is this number this number" question. */
raw?: unknown;
fetchedAt: string; // ISO instant
};
type Money = { amount: string; currency: string }; // decimal string, never number
type Party = {
id: string;
name: string;
roles: ("customer" | "supplier")[]; // Xero: both on one record. QBO: two records merged, or not.
sources: SourceRef[]; // plural — a merged party has two
taxNumber?: string;
defaultCurrency?: string;
};
type LineDimension = { key: string; optionId: string; label: string };
type DocumentLine = {
id: string;
kind: "account" | "item"; // NetSuite expense vs item sublist; QBO's two DetailTypes
description?: string;
quantity?: string;
unitAmount?: Money;
accountId?: string; // resolved even when the source only had an item
itemId?: string;
net: Money;
tax: TaxBreakdown; // never a bare scalar — see §11
dimensions: LineDimension[]; // fanned down from the header where the source put it there
source: SourceRef;
};
type LedgerDocument = {
id: string;
/** The discriminator all three encode differently. Ours is explicit. */
type: "sales_invoice" | "purchase_bill" | "sales_credit" | "purchase_credit"
| "cash_sale" | "cash_purchase" | "journal";
number?: string;
party?: Party;
issueDate: string; // calendar date
dueDate?: string;
currency: string;
exchangeRate?: string;
total: Money;
totalBase?: Money; // from the source, not recomputed
outstanding: Money;
/** Ours, derived where the source has none (QBO). Never read from the source directly. */
status: "draft" | "awaiting_approval" | "open" | "part_paid" | "paid" | "voided" | "deleted";
sourceStatus?: string; // the source's own word, for the tooltip
lines: DocumentLine[];
allocations: Allocation[];
periodId?: string;
source: SourceRef;
};
/** What the connected source can actually do. Components read this, not a hardcoded branch. */
type SourceCapabilities = {
dimensions: Dimension[]; // 0, 1, 2, or many — see §12
lineTarget: "account" | "item" | "both";
canCreateTaxRates: boolean; // false for QBO US under AST
hasApprovalWorkflow: boolean; // NetSuite only
hasGlFeed: boolean; // Xero Journals (Advanced tier + approval), NetSuite SuiteQL; false for QBO
hasAuditTrail: boolean; // NetSuite only
periodLock: { date?: string; perSubsidiary: boolean };
writeable: boolean;
};
Two properties of this model are the whole point.
SourceRef on every record, including raw. The question "why does this number look like that" is asked constantly in accounting software, and the only satisfying answer is the source payload. Components should be able to surface it in a detail drawer.
SourceCapabilities as data. Every difference in this document that changes what a user can do resolves to a capability flag. A component that branches on source.system === "xero" will be wrong the moment a fourth ledger appears — and there is always a fourth ledger.
18. What this changes in the UI
The differences that reach the surface, and what the component must do about them.
| Difference | Consequence for the component |
|---|---|
| QBO has no invoice status | Status is derived. Show the derivation on hover; never claim a status the source didn't assert. |
| Xero AR and AP share an endpoint | Every list is filtered by type. An unfiltered ledger table is a bug, not a view. |
QBO splits Bill from Purchase |
Vendor spend aggregates several document types. Label the total precisely: "billed" ≠ "spent". |
| Dimensions are 0–2 in Xero, n in NetSuite | Dimension controls render from metadata. No hardcoded Class/Department columns. |
| No GL feed in QBO | Statement components declare their provenance: reconstructed or reported. Show the tie-out variance. |
| Unapplied cash is a separate object in Xero | AR aging shows an explicit unapplied row and ties to the ledger's own aging. |
| Approval exists only in NetSuite | Approval state lives in our system; the ledger's approval is mirrored where it exists, and the control is hidden where it does not. |
| Audit trail exists only in NetSuite | Never claim "complete history". Say what we can see and when we last looked. |
| Period lock is a date, not a period, in two of three | Edit affordances are disabled before the lock date, with the date stated in the disabled state. |
| Xero daily call cap; NetSuite concurrency cap | Sync status is a first-class UI object: last synced, in progress, throttled, resumable. Never a spinner with no end. |
| Items required on QBO sales lines | The line editor's item field is required-per-capability, never required-per-component. |
| QBO full-replace writes | Edit flows read-then-write the whole record; optimistic UI must handle a SyncToken conflict as a real, user-visible outcome. |
19. Naming crosswalk
One row per concept, for the moment you are reading someone else's field name and need to know what it is.
| Concept | Xero | QBO | NetSuite |
|---|---|---|---|
| Sales invoice | Invoice (ACCREC) |
Invoice |
invoice |
| Vendor bill | Invoice (ACCPAY) |
Bill |
vendorbill |
| Cash sale | BankTransaction (RECEIVE) |
SalesReceipt |
cashsale |
| Card/cash purchase | BankTransaction (SPEND) |
Purchase |
check / expensereport |
| Customer credit | CreditNote (ACCRECCREDIT) |
CreditMemo |
creditmemo |
| Vendor credit | CreditNote (ACCPAYCREDIT) |
VendorCredit |
vendorcredit |
| Customer receipt | Payment |
Payment |
customerpayment |
| Supplier payment | Payment |
BillPayment |
vendorpayment |
| Unapplied receipt | Overpayment / Prepayment |
Payment.UnappliedAmt |
unapplied / customerdeposit |
| Manual journal | ManualJournal |
JournalEntry |
journalentry |
| GL posting | Journal (read-only) |
— | transactionaccountingline |
| Account | Account |
Account |
account |
| Customer | Contact (IsCustomer) |
Customer |
customer |
| Supplier | Contact (IsSupplier) |
Vendor |
vendor |
| Product/service | Item |
Item |
inventoryitem et al. |
| Tax rate | TaxRate |
TaxCode / TaxRate |
taxcode / taxgroup |
| Dimension | TrackingCategory |
Class / Department |
department / class / location / cseg_* |
| Entity/company | Organisation |
CompanyInfo |
subsidiary |
| Period lock | PeriodLockDate |
BookCloseDate |
accountingperiod |
| Attachment | Attachment |
Attachable |
file |
| Bank feed | BankTransaction / BankStatement |
Purchase/Deposit + banking feed |
transaction + bank import |
| Purchase order | PurchaseOrder |
PurchaseOrder |
purchaseorder |
| Quote/estimate | Quote |
Estimate |
estimate |
| Recurring | RepeatingInvoice |
RecurringTransaction |
recurring/memorised txn |
20. Using this document
Volatile facts are marked as approximate. Rate limits, page sizes, minor versions, and QBO's automated-sales-tax behaviour change without much notice. Treat every number in §16 as "check this", and treat the shapes — which are stable — as the durable content.
When a difference is discovered that is not in here, it belongs in here. The failure mode this document prevents is a per-integration workaround that nobody else learns about. Every row above was, at some point, somebody's surprise.
Adapters own the difference; components never see it. If a component prop needs to know which ledger it is talking to, either the canonical model in §17 is missing a field or SourceCapabilities is missing a flag. Fix it there.
Related: the accounting model · periods and close · order to cash · procure to pay · multi-entity and FX · controls and audit · lexicon