---
title: "Ledger APIs: Xero, QuickBooks Online, NetSuite"
description: "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."
canonical: "https://lopifi.com/docs/domain/ledger-apis"
---

# 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.

Source: https://lopifi.com/docs/domain/ledger-apis
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# 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](/docs/domain/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](/docs/domain/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:

```ts
// 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":

```jsonc
// 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](/docs/domain/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 `DetailType`s.

**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](/docs/domain/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](/docs/domain/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](https://developer.xero.com/pricing) 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:

1. **Reports API.** `GeneralLedger`, `TrialBalance` and `ProfitAndLoss` return 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.
2. **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.
3. **`/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.

```sql
-- 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](/docs/foundations/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:

```ts
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](/docs/domain/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](/docs/domain/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](/docs/domain/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 `.NET` epoch strings (`"/Date(1749254400000+0000)/"`) on some endpoints alongside ISO-8601 on others — parse both. QBO uses ISO-8601 dates for transactions and full timestamps in `MetaData`. 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](/docs/foundations/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.

```ts
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](/docs/domain/accounting-model) · [periods and close](/docs/domain/periods-and-close) · [order to cash](/docs/domain/order-to-cash) · [procure to pay](/docs/domain/procure-to-pay) · [multi-entity and FX](/docs/domain/multi-entity-and-fx) · [controls and audit](/docs/domain/controls-and-audit) · [lexicon](/docs/domain/lexicon)
