# ERP Accounting APIs — Research Report **Purpose:** Ground the design of AIR's `Backend` interface (`capabilities() / compile() / post() / reverse()`) in the reality of the ERP APIs we intend to target. **Research method:** 9 targeted web searches/fetches, official developer documentation prioritized. **Consultation date: 2026-08-05.** --- ## 1. QuickBooks Online (QBO) — AIR's first real backend ### 1.1 API model - REST + JSON, base URL `https://quickbooks.api.intuit.com/v3/company/{realmId}/...` (sandbox: `https://sandbox-quickbooks.api.intuit.com/...`). The `realmId` is the company ID, obtained during the OAuth flow. - **Minor versions:** since **August 1, 2025**, minor versions 1–74 are deprecated; **minor version 75 is the base/default** and any `minorversion` parameter below 75 is ignored. Recommendation: pin requests explicitly (`?minorversion=75`) and validate schema compatibility against mv75. - **Rate limits:** ~500 requests/minute per company (realm ID), max ~10 concurrent requests, and ~120 requests/minute on the Batch endpoint. Throttling returns HTTP 429 → backend must implement backoff + retry. ### 1.2 Authentication - OAuth 2.0 Authorization Code flow via Intuit's identity platform. Access tokens expire after **1 hour**; refresh tokens must be used (and rotated/persisted). Scope: `com.intuit.quickbooks.accounting`. - Free **sandbox companies** are available in the developer dashboard for full end-to-end testing (Phase 3 target). ### 1.3 JournalEntry entity Reference: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/journalentry - A `JournalEntry` is a header + array of `Line` objects. - Each line: `Description`, `Amount` (always **positive**), `DetailType: "JournalEntryLineDetail"`, and a `JournalEntryLineDetail` object containing: - `PostingType`: **`"Debit"` or `"Credit"`** — the debit/credit convention is an *explicit enum per line*, with an unsigned amount. - `AccountRef` (`value` = account ID, `name` optional) — reference to the Account. - Optional `Entity` (Customer/Vendor/Employee reference — required for lines on AR/AP accounts), class/department refs, `TaxCodeRef`/`TaxApplicableOn` for tax lines. - Header: `TxnDate`, `DocNumber`, `PrivateNote`, `CurrencyRef` + `ExchangeRate` (multicurrency companies). - Operations: create (POST), read, full update, sparse update, **delete** (`?operation=delete` with Id + SyncToken). QBO uses **optimistic concurrency via `SyncToken`** — every update/delete must supply the current SyncToken or fails. - **No native "reverse" operation on JournalEntry**: reversal = either delete the entry, or post a mirror-image journal entry (swap Debit/Credit) — AIR should generate an explicit reversal entry to preserve the audit trail rather than deleting. ### 1.4 Invoice and Payment entities - `Invoice`: header (CustomerRef, TxnDate, DueDate, CurrencyRef, DocNumber) + `Line[]` with `DetailType: "SalesItemLineDetail"` (`ItemRef`, `Qty`, `UnitPrice`, `TaxCodeRef`). Tax computed via `TxnTaxDetail` (and Automated Sales Tax in US companies). - `Payment`: separate entity linked to invoices via **`LinkedTxn`** (`TxnId`, `TxnType: "Invoice"`). `LinkedTxn` on the Invoice side is read-only; linkage is written from the Payment side. - This matters for AIR: a `Sale` economic event may compile to an Invoice + Payment pair rather than a raw JournalEntry — the backend's `capabilities()` should advertise which target document types it can emit. ### 1.5 Idempotency - QBO does **not** support modern `Idempotency-Key` headers. Two mechanisms: 1. **`requestid` query parameter** (a UUID unique per company): if the service receives a duplicate `requestid`, it returns the original response instead of re-executing. Intuit strongly recommends sending it on every mutating request — this is our primary idempotency mechanism. 2. Fallback: enable `CustomTxnNumbers` and rely on `DocNumber` uniqueness (duplicate → error 6140), usable as a duplicate detector. Sources (consulted 2026-08-05): - https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/journalentry - https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-linked-transactions - https://blogs.intuit.com/2015/04/06/15346/ (Request ID / idempotency) - https://medium.com/intuitdev/changes-to-our-accounting-api-that-may-impact-your-application-c330bd1a06f5 (minor version 75) - https://docs.codat.io/updates/250219-qbo-minor-versions-update/ - https://satvasolutions.com/blog/quickbooks-online-api-guide (rate limits) - https://truto.one/blog/how-to-integrate-with-the-quickbooks-online-api-2026-guide --- ## 2. Xero Accounting API ### 2.1 API model & auth - REST + JSON, base `https://api.xero.com/api.xro/2.0/...`. OAuth 2.0 only (OAuth 1.0a fully retired). - **Tenant model:** one OAuth connection can grant access to multiple Xero organisations; **every request must carry the `Xero-Tenant-Id` header**. The backend must persist the (token, tenantId) pair per organisation. - **Rate limits:** 60 calls/min per tenant, 5,000 calls/day per tenant, 10,000 calls/min app-wide. Daily per-tenant cap is the binding constraint for batch posting — favor batch endpoints (up to multiple elements per PUT/POST call). ### 2.2 ManualJournals Reference: https://developer.xero.com/documentation/api/accounting/manualjournals - Fields: `Narration` (required), `Date`, `Status`, `JournalLines[]`. - Each `JournalLine`: `LineAmount`, `AccountCode`, `Description`, `TaxType`, `Tracking` (analytic categories). - **Debit/credit convention: signed amount** — **positive = debit, negative = credit**. No PostingType enum. At least one debit and one credit line; the journal must balance. - `Status`: `DRAFT` or `POSTED` on creation; a posted manual journal is removed by setting status to **`VOIDED`** (drafts → `DELETED`). Voiding is the sanctioned "reversal" path, but AIR may still prefer an explicit contra-journal for traceability. - Invoices: `Invoices` endpoint (Type ACCREC/ACCPAY, LineItems with LineAmountTypes Exclusive/Inclusive/NoTax), separate `Payments` endpoint. ### 2.3 Idempotency - **Native support:** `Idempotency-Key` HTTP header (UUID) on POST/PUT — retries do not create duplicates. This is the cleanest idempotency model of all surveyed APIs. Sources (consulted 2026-08-05): - https://developer.xero.com/documentation/api/accounting/manualjournals - https://developer.xero.com/documentation/guides/oauth2/limits/ - https://developer.xero.com/faq/limits - https://xeroapi.github.io/xero-python/v1/accounting/index.html (idempotency_key parameter on ManualJournals operations) - https://cdn.cdata.com/help/DXF/jdbc/pg_accountingtable-manualjournals.htm (sign convention, balance requirement) --- ## 3. Odoo ### 3.1 API model & auth - External API via **XML-RPC** (`/xmlrpc/2/common`, `/xmlrpc/2/object`) and **JSON-RPC** (`/jsonrpc`); a REST-ish surface exists since Odoo 17. All protocols hit the same ORM (`execute_kw(db, uid, password, model, method, args)`). - **Deprecation:** `/xmlrpc`, `/xmlrpc/2` and `/jsonrpc` are scheduled for removal in **Odoo 22 (fall 2028)** / Online 21.1 (winter 2027), replaced by the **External JSON-2 API**. AIR's Odoo backend should abstract transport so the RPC layer can be swapped. - Auth: database + login + API key (used as password in RPC). External API access requires a **Custom pricing plan** (not available on One App Free / Standard) — a real deployment constraint. ### 3.2 Journal entries: `account.move` - One model for everything: journal entries, customer invoices, vendor bills, credit notes — discriminated by `move_type` (`entry`, `out_invoice`, `in_invoice`, `out_refund`, ...). - Lines in **`line_ids`** (`account.move.line`), each with **separate `debit` and `credit` decimal fields** (one of the two is 0.0), `account_id`, `partner_id`, `name`. Invoice-style lines go through `invoice_line_ids`. - Lifecycle via `state`: `draft` → `posted` → `cancel`. Post with **`action_post`** (`execute_kw(..., 'account.move', 'action_post', [[move_id]])`); back to draft with `button_draft`. - Balance is validated at post time (debits = credits); to build temporarily unbalanced drafts pass context `{'check_move_validity': False}`. - **Reversal:** first-class — `_reverse_moves` / reversal wizard creates a contra move with `reversed_entry_id` linking back to the original (good fit for AIR's provenance graph). ### 3.3 Idempotency - **None built in.** RPC create calls are not idempotent; the backend must implement its own dedupe (e.g., store the AIR event ID in `ref`/a custom field and search before create). Sources (consulted 2026-08-05): - https://www.odoo.com/documentation/19.0/developer/reference/external_rpc_api.html - https://www.odoo.com/documentation/19.0/developer/reference/external_api.html (External JSON-2) - https://github.com/odoo/odoo/blob/14.0/addons/account/models/account_move.py - https://www.odoo.com/forum/help-1/creating-journal-entries-via-external-api-xml-rpc-130818 - https://www.getknit.dev/blog/odoo-api-integration-guide-in-depth --- ## 4. SAP S/4HANA (brief survey — defer details, complexity high) - **Modern path:** SOAP/OData APIs under the **Finance – Posting Integration (SAP_COM_0002)** communication scenario: *Journal Entry – Post* (synchronous and asynchronous) and *Journal Entry – Change*, plus OData read APIs (e.g., Operational Journal Entry Item – Read). Journal entries are complex documents: header (company code, document type, posting date) + typed item categories (G/L items, debtor items, creditor items, product tax items, withholding tax items). Debit/credit is expressed via signed amounts / debit-credit codes depending on the API. - **Reversal:** a dedicated *Journal Entry – Reverse* service; requires `ReversalReason` and `ReversalReferenceDocument`, with **no line items** — reversal is a header-level operation referencing the original document. Sync and async variants exist. - **Legacy path:** `BAPI_ACC_DOCUMENT_POST` (post) and `BAPI_ACC_DOCUMENT_CHECK` (validate-only — interesting analogue of AIR's `Validate()`), plus IDoc-based batch interfaces. On-premise landscapes still rely heavily on these. - Complexity drivers: communication arrangements setup, document splitting, parallel ledgers, tax determination inside SAP, strict field controls per document type. **Decision: defer SAP backend; requires a dedicated deep research pass (OData vs SOAP vs BAPI, S/4HANA Cloud vs on-prem).** Sources (consulted 2026-08-05): - https://community.sap.com/t5/technology-blog-posts-by-sap/apis-for-journal-entries-the-collection-updated-july-2025/ba-p/13565258 - https://community.sap.com/t5/enterprise-resource-planning-blog-posts-by-sap/guidelines-for-api-journal-entry-post/ba-p/13421397 - https://help.sap.com/docs/SAP_S4HANA_CLOUD/b978f98fc5884ff2aeb10c8fdeb8a43b/57b40036b71f4825adad70a0a5b91573.html (Journal Entry – Reverse) - https://community.sap.com/t5/enterprise-resource-planning-blog-posts-by-sap/user-guide-for-journal-entry-post-api-on-s-4-hana-cloud/ba-p/13424193 --- ## 5. NetSuite SuiteTalk & Sage Intacct (brief capability survey) ### 5.1 NetSuite (SuiteTalk REST) - SuiteTalk REST Web Services: JSON, standard verbs (GET/POST/PATCH/DELETE) on records including **`journalEntry`** (`/services/rest/record/v1/journalEntry`). Legacy SOAP SuiteTalk still exists. - Journal entry = header (subsidiary, currency, trandate) + `line` sublist; each line has account reference and **separate `debit` / `credit` fields** (Odoo-like convention). - Auth: **Token-Based Authentication (OAuth 1.0-signed, via an Integration Record with consumer key/secret + token)** or **OAuth 2.0** (auth code and client-credentials machine-to-machine flows). Setup ceremony is significant (integration record, roles, permissions). - Reversal: native support via reversal date fields on the journal entry record (creates a linked reversing entry). No native idempotency-key header; use `externalId` on records for dedupe/upsert. Sources (consulted 2026-08-05): - https://www.moderntreasury.com/journal/how-to-authenticate-to-netsuites-suitetalk-rest-web-services-api - https://yepcode.io/recipes/rest-api-to-oracle-netsuite-journal-entries/ - https://www.brokenrubik.com/blog/netsuite-rest-api-guide ### 5.2 Sage Intacct - **Two API surfaces:** legacy **XML API** (envelope: ``, two-level authentication: sender ID/password + company/user credentials) and a newer **REST API with OAuth 2.0** — all new objects/features ship on REST only. - Journal entries: XML API `GLBATCH` object (journal entry is an owned element of a GL transaction/batch — header journal + `GLENTRY` lines with `TR_TYPE`/amount or debit-credit indicators); REST API exposes `general-ledger/journal-entries` create endpoint on the developer portal. - Dimensions (department, location, class, project) are pervasive on lines — the AIR line model must carry analytic dimensions generically. Sources (consulted 2026-08-05): - https://developer.intacct.com/api/general-ledger/journal-entries/ - https://developer.sage.com/intacct/apis/intacct/1/intacct-openapi/groups/general-ledger/groups/journal-entries/tags/general_ledger_journal-entries/paths/create-general-ledger-journal-entry - https://www.getknit.dev/blog/sage-intacct-api-integration-guide-in-depth --- ## 6. Comparison matrix | Aspect | QBO | Xero | Odoo | SAP S/4HANA | NetSuite | Intacct | |---|---|---|---|---|---|---| | Protocol | REST/JSON | REST/JSON | XML-RPC/JSON-RPC (→ JSON-2) | OData/SOAP (+BAPI/IDoc) | REST/JSON (+SOAP) | XML + REST | | Auth | OAuth 2.0 (1h tokens) | OAuth 2.0 + Xero-Tenant-Id | API key via RPC login | Comm. arrangement / OAuth | TBA (OAuth1) or OAuth 2.0 | 2-level XML creds / OAuth 2.0 | | D/C convention | `PostingType` enum + unsigned amount | **signed** LineAmount (+debit / −credit) | separate `debit`/`credit` fields | item categories + D/C codes | separate `debit`/`credit` fields | GLENTRY type/amount | | Idempotency | `requestid` query param | `Idempotency-Key` header | none (manual dedupe) | async msg IDs / none simple | `externalId` upsert | none simple | | Reversal | delete or contra entry (manual) | VOID status | `_reverse_moves` (linked contra) | dedicated Reverse API (reason + ref) | native reversal date | contra entry | | Rate limits | 500/min/realm | 60/min + 5k/day/tenant | server-dependent | gateway-dependent | account-tier concurrency | account-tier | | Sandbox | free sandbox company | demo company | local/docker instance | trial/CAL complex | dev account (paid) | dev account | --- ## 7. Implications for AIR backend interface The common `Backend` interface (`capabilities() / compile() / post() / reverse()`) must accommodate: 1. **Debit/credit representation is backend-specific.** AIR's `CompiledJournal` should use one canonical internal convention (explicit `side: Debit|Credit` + unsigned `Decimal` amount is the safest, lossless superset); each backend's `compile()` maps it to signed amounts (Xero), PostingType enums (QBO), or paired debit/credit fields (Odoo, NetSuite). 2. **`capabilities()` must be rich**, declaring at minimum: supported document types (raw journal entry vs invoice/payment documents), multicurrency support, tax-line handling (does the target recompute tax — QBO AST, SAP — or accept our tax lines — Xero, Odoo), analytic dimensions (Xero Tracking, Intacct dimensions, Odoo analytic), draft-then-post vs direct post, max lines/batch sizes, and **native idempotency mechanism**. 3. **Idempotency is an adapter concern with a common contract.** The interface should require: `post(payload, idempotency_key)` where the key is derived from the AIR event ID + policy version. Adapters map it to `Idempotency-Key` (Xero), `requestid` (QBO), `externalId` (NetSuite), or a search-before-create fallback (Odoo, Intacct). Every backend MUST persist the (AIR event → target document ID) mapping in the `PostingReceipt`. 4. **`reverse()` must support three strategies**, selected via capabilities: (a) native reversal API (SAP, NetSuite, Odoo `_reverse_moves`), (b) status change/void (Xero VOIDED), (c) synthesized contra entry (QBO, Intacct, generic CSV). Regardless of mechanism, AIR always records a reversal event in the provenance graph; deletion of posted entries is forbidden even where the API allows it (QBO delete). 5. **Concurrency/version tokens**: receipts must carry target-side version tokens (QBO `SyncToken`) so later reverse/update operations don't fail on optimistic locking. 6. **Rate limiting and retries belong in a shared posting runtime** (token bucket per realm/tenant, exponential backoff on 429/503, batch endpoints where available: QBO Batch ≤ ~30 ops, Xero batched PUT/POST) — not reimplemented per backend. 7. **Multi-tenancy is first-class:** a backend connection = (credentials, tenant/realm/db identifier, environment sandbox|prod). QBO realmId, Xero tenantId, Odoo db, SAP company code must be part of the connection config, never of the compiled journal. 8. **API versioning must be pinned per backend** (QBO `minorversion=75`, Odoo transport migration to JSON-2 by 2028, Intacct XML→REST) and surfaced in `capabilities()` so golden tests can be version-scoped. 9. **Validate-before-post** is natively supported by some targets (SAP `BAPI_ACC_DOCUMENT_CHECK`, Odoo draft state, Xero DRAFT status) — the interface should expose an optional `dry_run`/draft mode used by AIR's Approval pass before final posting. 10. **Ordering of backend development confirmed:** generic CSV → QBO (best docs, free sandbox, simple entity model) → Xero (cleanest idempotency) → Odoo (open source, self-hostable test instance) → SAP last (deep dedicated research required).