Ledger APIs: Xero, QuickBooks Online, NetSuite
Xero, QuickBooks Online and NetSuite each have their own idea of what an invoice is. This page compares them object by object, so a component built against one still works on the other two.
Everything in this system reads from somebody else's ledger. The integration layer produces a normalized model and the components render it. Most accounting products break in that handoff, usually by assuming "invoice" means the same thing in all three systems.
The rule: normalize the shape, never the semantics. A field that exists in only one system survives normalization as an explicit, nullable, source-tagged field. It never becomes a default value.
Sections run from the most expensive differences (the data model) to the cheapest (mechanics).
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 |
Custom dimensions is the row that costs the most. Xero and QBO have a small, fixed set you could hardcode into a UI. NetSuite's are user-defined, so any component that supports NetSuite renders 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 artifact you fetch from a separate Journals endpoint — which, since March 2, 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 customizations, approval workflows and scripts that fire on write. A write doesn't update a row so much as enter a business process, which may reject it.
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 |
QBO ids are not globally unique. Invoice 145 and Bill 145 can both exist. Every cache, URL and component key has to be {realmId}:{entityType}:{id}. You find this out the first time you build a global search.
Multi-entity changes the data model. A Xero or QBO group is n disconnected tenants you consolidate yourself, so 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 you must respect on read and on write. Multi-entity and FX covers what the UI does about it.
Model the entity tree in your own schema whatever the source, and map NetSuite's subsidiaries into it instead of adopting them as the tree. Otherwise a mixed-source group, say two Xero orgs plus a NetSuite subsidiary (common in the mid-market), has no coherent parent.
3. Chart of accounts#
The one object all three roughly agree on, and the one that most often breaks the P&L anyway.
| 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 the separator, so a colon in an account name breaks naive parsing. NetSuite goes to any depth. A COA tree component has to render a flat list well and can't assume a parent link exists.
Account numbers are not reliable identifiers. They're optional in QBO (AcctNum is populated only when the preference is on) and on Xero bank accounts, though Xero requires a unique Code on every other account. All three let you edit them. Unique is not the same as stable: renumber the chart and every mapping keyed on the code breaks. Key on the source id and 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. Below that, QBO's AccountSubType (~80 values) is the finest of the three. Xero's Type is coarser but adds SystemAccount for the accounts a user must never post to. One shared taxonomy will lose detail, so map to the coarsest common type and keep the source type verbatim beside it. Statement grouping reads your type. Tooltips 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 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#
In Xero one Contact can be a customer and a supplier at once, and often legitimately is (a client who also subcontracts for you). QBO makes you create two records with no link between them, so the same company appears twice with different ids and separate balances.
If your canonical model is a single Party with role flags (the Xero shape), the QBO import has to decide whether Customer "Acme Ltd" and Vendor "Acme Ltd (V)" are one party. QBO won't let both be called "Acme Ltd", because DisplayName is unique across customers, vendors and employees, so one side usually arrives with a suffix. Do not auto-merge on name, exact or fuzzy. Stripping (V) gives you candidates, and a wrong merge corrupts both aging reports. Merge only on an explicit user action or a strong identifier like a tax id, and keep both source ids on the merged party.
If your canonical model keeps customers and suppliers separate (the QBO shape), a Xero contact that is both gets projected into two records, and any UI that goes from a party to all its transactions has to put them back together.
Pick one shape, write the decision down, and spell out the round trip in both directions.
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's no Status field, so you compute open, paid, overdue and voided yourself:
// 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 QBO void zeroes the amounts and keeps the record. Summing TotalAmt drops voided invoices from revenue, which is correct, but counting invoices over-counts. Xero marks the record VOIDED and keeps the original amounts.
2. QBO wants items; Xero wants accounts. A Xero invoice line can post straight to AccountCode: "200". A QBO line posts to an Item, and the item carries IncomeAccountRef. Same GL result, different write path. To create a QBO invoice from a source that only has an account, you resolve or create a service item pointing at that account, and that item sits in the customer's product list forever. Set up a dedicated "integration" item set instead of minting one item per account on the fly.
3. Dimension placement. Xero always puts tracking on the line. QBO puts DepartmentRef on the header and ClassRef on the line or header, depending on a company preference. NetSuite allows both, and the line overrides the header. A dimensional P&L component accepts per-line dimensions and expects some sources to send one header value meant for every line. Fan that value down to the lines during normalization and record that you did. Otherwise an edit UI will write per-line values back to a system that can't 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. Normalize toward the journal and keep the payload differences 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. Tidy, and a trap for any component that renders "invoice": an AR aging table fed from /Invoices without a Type filter shows your payables as receivables. Always filter on Type. Never trust the endpoint name.
QBO splits accrual from cash. A vendor bill you'll pay later is a Bill. A card swipe or immediate payment is a Purchase (PaymentType: Cash | Check | CreditCard), which posts no AP at all. A "total spend with this vendor" figure that reads only Bill misses the card spend sitting in Purchase, and the gap can be large. Xero keeps the equivalent in BankTransactions (Type: SPEND). NetSuite has vendorbill versus check / expensereport / card charge.
This is the most common reason vendor-spend numbers come out wrong. The spend dashboard in procure to pay aggregates 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 take part in three-way match and inventory. Expense lines don't. Flatten both into one lines[] and you can't answer 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.
Only NetSuite has an approval workflow. Xero's SUBMITTED status is a flag. QBO's API has no approval concept at all. NetSuite has multi-step routing that your write can enter and never clear. A payables approval component that approves through the API works on NetSuite. On the other two, the approval state has to live in your system.
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 realized gain |
ExchangeRate; QBO computes |
exchangerate; NetSuite computes, per book |
The allocation shapes don't line up. Xero has N payment records, each pointing at one invoice. QBO and NetSuite have one payment record with N allocation lines. A canonical Payment { lines: Allocation[] } covers all three: Xero produces one-line payments, which you can group by BatchPayment id when there is one. Going from canonical to Xero splits 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 don't tell the user "payment recorded" until all N succeed.
Unapplied cash has three names. Xero splits it into Overpayment (the customer paid too much on an invoice) and Prepayment (the customer paid before an invoice existed). Both are separate objects with their own endpoints, and integrations that skip them can't get AR to 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 sums only invoice balances will miss 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 hold across all three.
Never render a credit note as a negative invoice. It's a separate document with its own number sequence, and finance teams reconcile credit notes on their own. Merge them into one signed column and the audit path is gone. See the accounting model §1.
Amounts are stored positive. All three store credit notes as positive amounts and encode the direction in the document type. If your normalization flips signs, do it in exactly one place and test the round trip. A double-negation bug here produces a plausible number that gets through review.
9. Journals and the general ledger#
The three diverge most here, and this is where reconciliation happens.
| Xero | QBO | NetSuite | |
|---|---|---|---|
| User-entered JE | ManualJournals |
JournalEntry |
journalentry |
| Full GL feed | Journals — read-only, every posting from every source. Since Mar 2, 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 most valuable one and the easiest to miss. It emits the GL itself, every posting from invoices, payments, bills, bank transactions and manual journals, as immutable, sequentially numbered journal records. Paginate by offset on JournalNumber and you get an append-only, gapless ledger feed you can replay from the start. Anything that reconciles or re-derives statements should be built on it instead of /Invoices, if you can get access. Since Xero's app tiers took effect on March 2, 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 no equivalent, and that's the biggest structural gap of the three. No endpoint returns GL postings. Your options, most practical first:
- Reports API.
GeneralLedger,TrialBalanceandProfitAndLossreturn report rows, nested and presentation-shaped, with header and summary rows to walk. The totals are right, the parsing is awkward, and rows have no stable ids. - Reconstruct from every transaction entity plus QBO's posting rules. You get full control, and you now own the correctness of QBO's tax and FX posting logic indefinitely.
/cdc(change data capture) keeps entity mirrors fresh, and you derive statements from the mirror.
Most teams settle on (1) for tie-out and (3) for detail. Either way, show which one produced the number. A P&L rebuilt from entities and one from the Reports API will sometimes differ, and the user needs to know which they're reading.
NetSuite gives you SQL. transactionline joined to transaction and accountingtransaction (or transactionaccountingline for debit/credit amounts by book) is the GL. It's the most powerful of the three and the most demanding: you have to 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')
One shape difference reaches your model. Xero hands you a signed NetAmount, QBO a PostingType enum, NetSuite two columns. Store two columns. Going from signed to two columns loses nothing. Going back loses information 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 |
In Xero items are optional, and most Xero invoices have none. QBO requires them on sales lines. NetSuite runs them as a whole subsystem with its own costing engine.
For a component library that comes down to one rule: an invoice line editor can't require an item and can't forbid one. Item is nullable. Whether it's required is a property of the connected source, delivered as capability metadata instead of a prop hardcoded per instance.
11. Tax#
The messiest area, and the one where normalizing everything into one shape fails worst.
| 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, honored inconsistently under AST |
Line-level override |
| Jurisdictions | Flat rate list | AST resolves by address | Nexus model — jurisdiction by subsidiary and address |
QBO US is a different product from QBO non-US. Under Automated Sales Tax you can't create tax rates through the API, and Intuit computes a transaction's tax from the addresses. Tax amounts you send may be overridden without an error. A tax UI is read-mostly for US QBO and editable elsewhere, driven by capability metadata instead of 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 the split is 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 show a rate, show 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 is why a dimensional filter bar can't be a static component. Xero gives you at most two, named by the user. NetSuite gives you any number, with names and field keys you discover at runtime (cseg_product, cseg_channel, …). A component that filters or groups by dimension takes:
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 renders n of them. Hardcoded "Class" and "Department" columns are wrong on two of the three systems.
Xero's two-category limit is a product limit, and no plan upgrade lifts it. A client who wants department, project and location on Xero can't have all three. The UI should say so instead of offering a control that can't 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 administrators (formerly advisers), EndOfYearLockDate blocks everyone |
One close date, optionally password-protected | Per period, per subsidiary, and separately for AP/AR/GL |
| Enforced on API writes? | Before EndOfYearLockDate, always fails. Before PeriodLockDate, it may succeed for an administrator's connection, so check the date yourself rather than relying on a 400 |
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 |
Only NetSuite has a period model. Xero and QBO have dates (two in Xero, and one of them may not stop an administrator's API writes), so a "period" in your system is your own construct, reconciled against a cut-off date. In periods and close, the close checklist treats period state as owned by your system and the ledger's lock date as one input to it.
On write paths, check the lock date before offering an edit. A disabled control reading "Period locked Aug 31" is the right UI. A 400 from Xero shown as a red toast after the user has typed for a minute is the wrong one.
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 exposes a field-level audit trail through the API. Xero gives you a history feed per record. QBO gives you timestamps with no actor. An audit or evidence component (see controls and audit) builds its own trail over your actions and states plainly that someone editing in the ledger's own UI may not show up. On QBO, they never do.
Attachments are the most underestimated cost in a ledger integration. QBO needs a second query per transaction unless you batch, and files are the largest thing you'll 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 return presentation output. Use them for tie-out, proving your reconstructed P&L equals the ledger's own. Don't use them to populate an interactive statement, because the rows have no reliable identity to drill from.
Build statements from GL data (or entity mirrors), run the report endpoint on a schedule, and assert the two are equal. When they disagree, show the variance. A controller will value that check more 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 |
NetSuite's GET /record/v1/invoice returns a page of {id, links} objects, not invoices. Fetching 5,000 invoices that way takes 5,001 calls against a concurrency limit in the single digits. Use SuiteQL for bulk reads, or the sync won't finish.
Deletes are the data-integrity problem nobody notices. Xero never hard-deletes. It flips the status, so a delta sync sees the change. QBO reports deletes only through /cdc, with a limited lookback window, so a poll-only integration piles up ghost records. NetSuite hard-deletes, and the record stops appearing. A full refresh has to reconcile the id set as well as 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 behavior in all three APIs. POST an Invoice with only the fields you meant to change and every line on it is deleted. Read the current record, change it, and write the whole thing back with the current SyncToken, or set sparse: true and accept its narrower semantics. Never build a QBO update payload from scratch.
Rate limits and concurrency#
Approximate, and the first thing vendors change. Check 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 Mar 2, 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 license |
| 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 |
These limits shape the architecture. Xero's daily cap makes a full-history backfill of a large org a multi-day job, so it has to be resumable. NetSuite caps parallelism hard, so queueing is centralized per account: two workers each "politely" using three connections will trip a five-connection limit. QBO is the most forgiving.
Build one token-bucket limiter per (source, tenant) and route every call through it, retries included. 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 send notifications without data. You receive "invoice X changed" and fetch it yourself. Verify the signature before trusting anything. Xero disables an endpoint that fails its validation handshake, so build the verification path before any business logic.
NetSuite has no webhooks. The substitutes are a User Event SuiteScript that calls out on save, or scheduled polling on lastmodifieddate. Most integrations poll, so freshness varies by source and the UI should show it. "Synced 4 minutes ago" is a component in its own right.
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. Normalize 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 them to a decimal type immediately, never let a float hold a monetary value, and carry the currency code with it.
- FX. Each transaction carries its own rate, and each system computes realized and unrealized gains its own way. Don't 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#
What the components render. Every adapter produces this, and nothing from the sections above appears in a component prop.
type SourceSystem = "xero" | "qbo" | "netsuite";
/** Every normalized 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 matter most.
SourceRef on every record, including raw. Users of accounting software ask "why is this number this number" all day, and the source payload is the only answer that settles it. Components can surface it in a detail drawer.
SourceCapabilities as data. Every difference on this page that changes what a user can do resolves to a capability flag. A component that branches on source.system === "xero" breaks when a fourth ledger shows up, and one always does.
18. What this changes in the UI#
The differences a user can see, and what the component does about each.
| 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 when you're staring at another system's field name.
| 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/memorized txn |
20. Using this document#
Volatile facts are marked approximate. Rate limits, page sizes, minor versions and QBO's automated-sales-tax behavior change with little notice. Check every number in §16 before relying on it. The shapes are stable and are what this page is for.
A difference you find that isn't here gets added here. Otherwise it becomes a per-integration workaround nobody else learns about. Every row above started as somebody's surprise.
Adapters own the differences and components never see them. If a component prop needs to know which ledger it's talking to, 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