Build a Developer Cookbook for Unified Accounting APIs (2026 Architecture Guide)
Unified API for QuickBooks, Xero, and NetSuite: endpoint reference, JSON examples, field-level mapping, error patterns, and hard-case eval tests.
If your B2B SaaS product writes to a customer's general ledger, handing your engineering team a raw API key and a link to the QuickBooks Online documentation is a recipe for technical debt. You need an internal developer reference that goes beyond auto-generated OpenAPI specs. A unified accounting API cookbook is the artifact that turns "how do I create an invoice in QuickBooks AND NetSuite AND Xero" from a week-long Slack thread into a 15-minute task for a new engineer or an AI agent.
This guide walks through exactly how to structure that cookbook, what to document, and the architectural patterns that keep it from becoming stale the moment a vendor ships a breaking change. The target reader here is a Senior PM or Engineering Lead at a B2B SaaS company who is tired of debugging the same NetSuite SuiteQL quirk for the third time this quarter.
Why You Need a Developer Cookbook for Unified Accounting API Usage
The global accounting software market is fragmented and growing fast. The global accounting software market was calculated at USD 21.16 billion in 2025 and is predicted to increase from USD 23.1 billion in 2026 to approximately USD 50.79 billion by 2035, expanding at a CAGR of 9.15%. North America is expected to lead the global accounting software market during the forecast period 2026 to 2035.
Your customer base will not standardize on a single platform. A mid-market customer uses QuickBooks Online. An enterprise prospect runs NetSuite OneWorld with multi-subsidiary consolidation. An international client relies on Xero with multi-currency. The deeper reason for this shift: cloud-based deployment held the largest market share at 68% in 2025, which means almost every prospect now expects bi-directional API access to their ledger.
Integration overload is becoming a massive growth bottleneck for scaling SaaS companies, draining engineering hours and slowing product velocity. With median customer acquisition costs (CAC) hitting $2.00 to acquire $1.00 of new ARR, retaining customers through deep, reliable product integrations is a strict requirement. If your product cannot write invoices, post journal entries, or reconcile transactions in real time, and your answer to financial syncing involves manual CSV exports, you will lose the deal.
A cookbook is the asset that lets your engineering org keep pace with this fragmentation. Without one, every new integration request hits the same engineer who happens to remember that Xero treats LineItems differently from QuickBooks. With one, you have a single source of truth that humans, LLM agents, and your support team can all reference.
Good cookbooks share four traits:
- Entity-first organization: Documented by
Invoices,JournalEntries,Contacts, not by provider. - Workflow recipes: Concrete "create invoice, apply payment" examples, not just endpoint references.
- Failure mode catalogs: What happens when a token expires, a 429 fires, or a custom field is missing.
- Hot-swappable mappings: The cookbook references mappings as data, so updates do not require a redeploy.
If your integrations live as scattered files of if (provider === 'quickbooks') branches, no amount of documentation will save you. The cookbook reflects the architecture. Start there.
The Architectural Reality of Accounting APIs
Accounting APIs are not flat CRM databases. They are stateful, double-entry ledgers where every financial movement must balance. You cannot simply POST an invoice and forget about it. You must ensure the associated customer exists, the line items map to active ledger accounts, the tax rates are valid for the subsidiary, and the transaction period is open.
To manage this complexity, your developer cookbook must abstract provider-specific quirks into a single, predictable interface. This requires standardizing data models, authentication, and error handling so your core application logic remains entirely decoupled from the underlying third-party API. For more context on why financial connectivity is expanding, review our guide on What Are Accounting Integrations?.
1. Defining the Core Entities and Data Model
The first section of your cookbook should define a canonical data model. Your reference must establish a canonical JSON schema that represents the full financial lifecycle. Developers should program against this unified schema, ignoring whether the target system calls a record a VendBill (NetSuite) or a Bill (QuickBooks).
A unified accounting model typically organizes around five logical domains. Document them in this order because it mirrors how an LLM agent or a new engineer thinks about money flowing through a business:
Core Financial Ledger & Configuration
CompanyInfo: Top-level metadata about the financial entity, including tax numbers, fiscal year boundaries, and base currency settings.Accounts: The Chart of Accounts. The fundamental categories (Assets, Liabilities, Equity, Revenue, Expenses) used to record financial transactions.JournalEntries: Double-entry accounting records that move balances between specificAccounts.TaxRates&Currencies: The active tax codes and currencies configured in the ledger.TrackingCategories: Departmental or project-based dimensional tags used to segment data (e.g., "Classes" in QuickBooks or "Departments" in NetSuite).
Accounts Receivable (Income)
Invoices: Itemized bills sent to customers.Payments: Records of funds received against specificInvoices.CreditNotes: Documents reducing the amount a customer owes.Items: The catalog of products or services the company sells, which populate invoice line items.
Accounts Payable (Expenses)
Expenses: Direct cash or credit card purchases.PurchaseOrders: Formal requests sent to vendors authorizing the purchase of goods.VendorCredits: Credits issued by a vendor applied against future bills.PaymentMethod: The specific method used to settle a payable.
Stakeholders & People
Contacts: External entities. This is a polymorphic resource encompassing both Customers (who payInvoices) and Vendors (who issuePurchaseOrders).ContactGroups: Logical groupings of stakeholders.Employees: Internal staff data needed for expense reimbursement mapping.
Reconciliation & Reporting
Transactions: Raw bank feed data requiring reconciliation.RepeatingTransactions: Scheduled, recurring ledger movements.Budgets: Financial planning thresholds.Reports: Standardized financial statements like the Profit & Loss or Balance Sheet.Attachments: Source-of-truth documentation (receipts, contracts) linked to financial records.
Here is the conceptual relationship between these entities:
graph LR A[Contacts<br>customers + vendors] --> B[Invoices] A --> C[PurchaseOrders] D[Items] --> B D --> C B --> E[Payments] C --> F[Expenses] E --> G[Accounts] F --> G G --> H[JournalEntries] H --> I[Reports] J[TrackingCategories] -.tags.-> B J -.tags.-> F K[Attachments] -.linked to.-> B K -.linked to.-> F
For every entity, the cookbook should specify: the unified field name, type, whether it is required, the underlying provider field for each integration, and a JSONata expression showing how the mapping is computed.
Pragmatic rule: Every unified field should preserve the original provider payload as remote_data. Your customers will eventually ask for a field you did not normalize, and you will thank yourself for keeping the raw response addressable.
2. Unified API Endpoint Reference: Invoices, Payments, Contacts, Items
Once your data model is defined, the cookbook needs a concrete endpoint reference. This is what an engineer opens when they need to know exactly what to send and what comes back for a single unified API that spans QuickBooks, Xero, and NetSuite. Every unified accounting endpoint follows the same shape:
- Base URL:
https://api.truto.one/unified/accounting - Required query param:
integrated_account_id- the connected account that routes the call to the target provider - Authentication: Bearer token in the
Authorizationheader - Response envelope:
{ result, next_cursor, prev_cursor, result_count }for list endpoints; a single object for get/create/update - Raw payload access: Every mapped result carries the untouched provider payload under
remote_data
The four endpoints that cover roughly 80% of accounting integration work are contacts, items, invoices, and payments. Below is the request/response shape for each, followed by a field-level mapping across QuickBooks Online, Xero, and NetSuite.
Contacts
Polymorphic across customers and vendors. The contact_type query parameter routes to the correct provider resource (NetSuite has separate customer and vendor records, QuickBooks has separate Customer and Vendor objects, Xero uses a single Contact with an IsCustomer / IsSupplier flag).
GET /unified/accounting/contacts?integrated_account_id=abc&contact_type=customer&limit=10Normalized response:
{
"result": [
{
"id": "1234",
"name": "Acme Corp",
"email_address": "billing@acme.com",
"emails": [{ "email": "billing@acme.com", "type": "primary" }],
"phones": [{ "number": "+1-555-0123", "type": "primary" }],
"addresses": [{
"street_1": "123 Main St",
"city": "San Francisco",
"state": "CA",
"postal_code": "94103",
"country": "US"
}],
"currency": "USD",
"status": "ACTIVE",
"contact_type": "customer",
"remote_data": { "...raw provider response...": true }
}
],
"next_cursor": "eyJvZmZzZXQiOjEwfQ==",
"prev_cursor": null,
"result_count": 10
}Create a vendor:
POST /unified/accounting/contacts?integrated_account_id=abc
Content-Type: application/json
{
"name": "Contoso Supplies",
"email_address": "ap@contoso.com",
"contact_type": "vendor",
"currency": "USD"
}Items
The catalog of products or services that populate invoice and purchase order line items.
GET /unified/accounting/items?integrated_account_id=abc&limit=5{
"result": [
{
"id": "8845",
"name": "Enterprise Plan - Monthly",
"description": "Enterprise tier, monthly billing",
"type": "service",
"unit_price": 499.00,
"currency": "USD",
"sales_account": { "id": "301", "name": "Software Revenue" },
"expense_account": null,
"tax_rate": { "id": "TAX_STANDARD" },
"status": "ACTIVE",
"remote_data": { "...": true }
}
],
"next_cursor": null,
"result_count": 1
}Invoices
The invoice endpoint supports both customer invoices (AR) and vendor bills (AP) via the invoice_type parameter (invoice or bill).
POST /unified/accounting/invoices?integrated_account_id=abc
Content-Type: application/json
{
"invoice_type": "invoice",
"contact": { "id": "1234" },
"issue_date": "2026-05-19",
"due_date": "2026-06-18",
"currency": "USD",
"line_items": [
{
"item": { "id": "8845" },
"description": "Enterprise Plan - May 2026",
"quantity": 1,
"unit_price": 499.00,
"tax_rate": { "id": "TAX_STANDARD" },
"tracking_categories": [{ "id": "CLASS_ENGINEERING" }]
}
],
"external_id": "local-inv-9f3a2c"
}Response:
{
"id": "INV-00421",
"invoice_number": "INV-00421",
"contact": { "id": "1234", "name": "Acme Corp" },
"issue_date": "2026-05-19",
"due_date": "2026-06-18",
"currency": "USD",
"status": "OPEN",
"sub_total": 499.00,
"total_tax": 44.91,
"total": 543.91,
"balance": 543.91,
"line_items": [{ "id": "L1", "item": { "id": "8845" }, "quantity": 1, "unit_price": 499.00 }],
"remote_data": { "...": true }
}Payments
Records money received against an invoice or paid against a bill.
POST /unified/accounting/payments?integrated_account_id=abc
Content-Type: application/json
{
"contact": { "id": "1234" },
"account": { "id": "1100" },
"payment_date": "2026-05-25",
"amount": 543.91,
"currency": "USD",
"applied_to_transactions": [
{ "transaction_id": "INV-00421", "amount": 543.91 }
]
}Response includes the server-assigned payment ID, the linked invoice, and any bank-side reference number preserved in remote_data.
Field-Level Mapping: QuickBooks ↔ Xero ↔ NetSuite ↔ Unified
The table below shows how each unified field is sourced from the underlying provider. This is a representative slice - the full mapping is stored as JSONata expressions that also handle nested structures, empty checks, and type coercion.
| Unified Field | QuickBooks Online | Xero | NetSuite (SuiteQL) |
|---|---|---|---|
invoices.id |
Invoice.Id |
Invoice.InvoiceID |
transaction.id |
invoices.invoice_number |
Invoice.DocNumber |
Invoice.InvoiceNumber |
transaction.tranid |
invoices.contact.id |
Invoice.CustomerRef.value |
Invoice.Contact.ContactID |
transaction.entity |
invoices.issue_date |
Invoice.TxnDate |
Invoice.Date |
transaction.trandate |
invoices.due_date |
Invoice.DueDate |
Invoice.DueDate |
transaction.duedate |
invoices.status |
Derived: Balance = 0 ? PAID : OPEN |
Invoice.Status (AUTHORISED, PAID, VOIDED) |
transaction.status (A→OPEN, B→PAID, C→CANCELLED) |
invoices.total |
Invoice.TotalAmt |
Invoice.Total |
transaction.foreigntotal |
invoices.currency |
Invoice.CurrencyRef.value |
Invoice.CurrencyCode |
currency.symbol (via JOIN) |
invoices.line_items [].unit_price |
Line.SalesItemLineDetail.UnitPrice |
LineItem.UnitAmount |
transactionline.rate |
invoices.line_items [].tax_rate.id |
Line.SalesItemLineDetail.TaxCodeRef.value |
LineItem.TaxType |
transactionline.taxcode |
invoices.line_items [].tracking_categories |
Line.SalesItemLineDetail.ClassRef.value |
LineItem.Tracking [].TrackingCategoryID |
transactionline.class + department + location |
contacts.name |
Customer.DisplayName / Vendor.DisplayName |
Contact.Name |
entity.companyname or firstname + ' ' + lastname |
contacts.email_address |
Customer.PrimaryEmailAddr.Address |
Contact.EmailAddress |
entity.email |
contacts.currency |
Customer.CurrencyRef.value |
Contact.DefaultCurrency |
currency.symbol (via JOIN) |
contacts.status |
Customer.Active |
Contact.ContactStatus |
entity.isinactive inverted |
items.type |
Item.Type (Service, Inventory, NonInventory) |
Item.IsSold / Item.IsTrackedAsInventory |
item.itemtype (Service, NonInvtPart, InvtPart) |
items.unit_price |
Item.UnitPrice |
Item.SalesDetails.UnitPrice |
item.baseprice |
payments.amount |
Payment.TotalAmt |
Payment.Amount |
transaction.foreigntotal |
payments.account.id |
Payment.DepositToAccountRef.value |
Payment.Account.AccountID |
transactionaccountingline.account |
payments.applied_to_transactions [] |
Payment.Line [].LinkedTxn [] |
Payment.Invoice.InvoiceID |
transactionline (link records) |
Provider-Specific Caveats
The unified model normalizes the common cases, but a few provider behaviors leak through. Call these out in your cookbook so engineers know where the abstraction ends.
Tax handling:
- QuickBooks Online applies tax at the line-item level via
TaxCodeRef. The tenant may operate in either "Automated Sales Tax" (AST) mode or manual mode. In AST mode you cannot set the tax amount directly - QuickBooks computes it from the customer's shipping address and product taxability. - Xero attaches a
TaxTypestring to each line item (OUTPUT,INPUT,NONE, or region-specific codes likeOUTPUT2for UK 20% VAT). The validTaxTypeset depends on the tenant's country. - NetSuite exposes tax as
salestaxitemrecords, but SuiteQL does not return the full rate configuration. Full tax rate details require a fallback SOAPgetListcall, which the unified/tax_ratesendpoint abstracts behind a single request.
Multi-currency:
- QuickBooks Online requires multi-currency to be explicitly enabled in company preferences, and it cannot be disabled once turned on. Every foreign-currency transaction carries an
ExchangeRatefield. - Xero supports multi-currency on Premium plans only. Standard-plan orgs reject any invoice with a
CurrencyCodedifferent from the org's base currency. - NetSuite OneWorld accounts include per-subsidiary base currencies. The
context.multi_currencyandcontext.multi_subsidiaryflags detected at connection time control whether SuiteQL queries JOIN thecurrencyandsubsidiarytables. Single-currency accounts default toUSDin the unified response.
Classes and tracking categories:
- QuickBooks Online calls them "Classes" (
ClassRef) and "Departments" (DepartmentRef). Both must be enabled in company preferences before they can be referenced on a transaction. - Xero calls them "Tracking Categories" and allows up to two active categories per org. Line items can carry up to one option from each active category.
- NetSuite exposes three separate dimensions:
class,department, andlocation. The unifiedtracking_categoriesresource uses acategory_typeparameter (CLASS,DEPARTMENT, orLOCATION) to route to the correct NetSuite table.
Common Error Responses and Recovery Patterns
The unified API normalizes upstream errors into a consistent envelope. The HTTP status code matches the semantic class of the failure, and the body preserves the raw provider error under provider_error so you can debug without opening a support ticket.
{
"error": {
"code": "invalid_field",
"message": "The 'tax_rate.id' value 'TAX_BOGUS' does not exist on this integrated account.",
"field": "line_items[0].tax_rate.id",
"provider_error": {
"type": "ValidationFault",
"detail": "Invalid Reference Id : TaxCodeRef.value"
}
}
}| Status | Meaning | Recovery Pattern |
|---|---|---|
400 Bad Request |
Validation failed. Missing required field, invalid enum, malformed date. | Fix the request and retry. Do not blindly retry - the same input will fail identically. |
401 Unauthorized |
Token expired or was revoked. The proactive refresh flow already ran. | Mark the account needs_reauth and prompt the user to reconnect. |
403 Forbidden |
The integration user lacks permission. NetSuite role permissions or QuickBooks user access level. | Surface to the customer. Do not retry. |
404 Not Found |
Referenced record does not exist or was deleted upstream. | Check whether the record was merged (QuickBooks customer merge) or made inactive. Refresh your local cache. |
409 Conflict |
Duplicate detected via external_id, or a concurrent modification collision. |
Query by external_id before retrying. Do not force-create. |
422 Unprocessable |
Business rule violation. Closed period, unbalanced journal, negative inventory. | Surface to the user. The rule will not change on its own. |
429 Too Many Requests |
Upstream rate limit hit. | Read ratelimit-reset, sleep, then retry with backoff (see Section 4). |
500 / 502 / 503 |
Upstream server error. Common during QuickBooks and NetSuite maintenance windows. | Exponential backoff with jitter, capped at 5 retries. Escalate to on-call if it persists longer than 15 minutes. |
For write operations that timed out mid-flight (a network partition after the request was sent but before the response arrived), do not retry blindly. Query by your external_id first to determine whether the write actually succeeded. This is the single most common cause of duplicate invoices in production.
3. Authentication, OAuth, and Token Management
A unified accounting API cookbook must strictly define how authentication is handled. The authentication section of the cookbook is where most internal docs fall apart. Vendors handle auth wildly differently, and the operational implications compound at scale.
Token/OAuth Lifecycle and Failure Modes
Here is the token lifecycle reality for the three most common accounting integrations:
| Provider | Auth Type | Access Token TTL | Refresh Token Behavior | Primary Failure Mode |
|---|---|---|---|---|
| QuickBooks Online | OAuth 2.0 (Authorization Code) | 60 minutes | Historically 100-day validity. Intuit announced in late 2025 a maximum 5-year validity with rotation roughly every 24-26 hours. Old refresh token invalidated on use. | Refresh race conditions on parallel workers; occasional 401s even on valid tokens after Intuit-side auth infra changes. |
| Xero | OAuth 2.0 (Authorization Code, PKCE) | 30 minutes | 60 days from last use. Rotates on every refresh - old refresh token is unusable immediately. A 30-minute grace period exists for retrying a stale refresh. | invalid_grant errors from concurrent refreshes or failing to persist the rotated refresh token. |
| NetSuite | OAuth 1.0 Token-Based Auth (TBA) | Long-lived (no expiry) | No refresh flow. HMAC-SHA256 signature computed on every request. | User revokes tokens in the NetSuite UI; role permission changes silently break specific endpoints. |
Document three things explicitly:
- Connection flow: For each provider, the exact OAuth grant type, scopes, and any custom claims (audience, tenant subdomain, sandbox flag).
- Credential storage contract: Your reference must specify that all OAuth tokens, refresh tokens, and API keys are encrypted at rest, a baseline requirement for secure financial data APIs. The execution engine should only decrypt these values in memory at the exact moment the HTTP request is constructed.
- Refresh behavior: When tokens refresh, what happens on failure, and how the system surfaces a
needs_reauthstate to your application.
The Token Lifecycle
Access tokens expire quickly. Your cookbook must dictate a proactive refresh strategy. Do not wait for a 401 Unauthorized response to trigger a token refresh. This creates race conditions and unnecessary latency.
Instead, the platform should schedule work ahead of token expiry. Check the token's Time-To-Live (TTL) before every outbound API call. If the token expires within a 30-second to 180-second buffer window, proactively execute the refresh grant. Truto refreshes OAuth tokens shortly before they expire and serializes concurrent refresh attempts per account so parallel sync jobs and API requests do not race each other into a invalid_grant state.
Handling Re-Authentication
Refresh tokens can be revoked by the user, invalidated by the provider, or expire due to inactivity. When a refresh attempt fails, your system must immediately mark the integrated account status as needs_reauth and emit an integrated_account:authentication_error webhook to your core application.
// Example: handling the needs_reauth webhook in your app
app.post('/webhooks/integration-events', async (req, res) => {
const { event, integrated_account_id } = req.body
if (event === 'integrated_account:authentication_error') {
await db.connections.update(integrated_account_id, {
status: 'reauth_required',
banner_message: 'Reconnect your accounting system to resume sync.',
})
}
res.status(200).end()
})When the user successfully completes the OAuth flow again, emit an integrated_account:reactivated webhook to resume paused synchronization jobs. Do not paper over the fact that NetSuite's OAuth 1.0 with HMAC-SHA256 signing on every request is fundamentally more painful than OAuth 2.0 bearer tokens. The cookbook should call this out so engineers know which integrations need extra care under load.
4. Handling Rate Limits and Pagination
Rate limits are where naive integrations die. Accounting APIs impose severe rate limits to protect their infrastructure, and each provider does it differently.
Per-Provider Rate Limits and How We Handle Them
| Provider | Steady-State Limit | Concurrency / Burst | Notes |
|---|---|---|---|
| QuickBooks Online | 500 requests/minute per realm ID | 10 concurrent per app per realm | Batch endpoint: 120/minute per realm (raised from 40 in October 2025). Resource-intensive report endpoints capped at 200/minute. Reads are metered under the CorePlus tier; writes are free. |
| Xero | 60 calls/minute per tenant; 5,000 calls/day per tenant | 5 concurrent per tenant | Returns HTTP 429 with a Retry-After header telling you how many seconds to wait. App-wide daily ceilings also apply. |
| NetSuite | Rate-based limits vary by service tier and are enforced on 60-second and 24-hour windows | Account-wide concurrency pool covers SOAP + REST + RESTlet combined. Developer accounts start at 5; each SuiteCloud Plus license adds 10. Service tier determines the base cap. | Concurrency is the primary bottleneck, not per-minute rate. Long-running SuiteQL queries hold a slot for the full request duration. |
A unified accounting API should normalize each of these into a single header contract for the caller. Truto follows the IETF draft for RateLimit headers, exposing ratelimit-limit, ratelimit-remaining, and ratelimit-reset on every response, regardless of which provider is upstream. Under the hood, the platform translates each vendor's native rate limit signal (Intuit's X-RateLimit-* headers, Xero's Retry-After, NetSuite's concurrency rejection responses) into the same three headers.
Important behavior to document: Truto does not retry, throttle, or absorb 429 errors. When an upstream API returns HTTP 429 (Too Many Requests), Truto passes that error directly to the caller along with the normalized rate limit headers.
Why? Because the calling application holds the business context. A background data sync can safely sleep for five minutes, but a user-facing action (like clicking "Generate Invoice") requires an immediate UI failure state. Silently retrying can mask quota issues, mangle write idempotency, and create unpredictable latency.
Here is a minimal backoff pattern for your client SDK that developers should use to implement exponential backoff with jitter in their own workers:
async function callWithBackoff(req, attempt = 0) {
const res = await fetch(req)
if (res.status !== 429 || attempt >= 5) return res
const resetTime = Number(res.headers.get('ratelimit-reset') ?? 1)
const waitMs = (resetTime * 1000) - Date.now() + Math.random() * 1000;
console.warn(`Rate limited. Waiting ${waitMs}ms before retry.`);
await new Promise(resolve => setTimeout(resolve, waitMs));
return callWithBackoff(req, attempt + 1)
}Pagination Standardization
Third-party APIs paginate differently. Some use offset/limit, others use cursor strings, range pagination, and some rely on Link headers. Your cookbook must define a single pagination interface. Developers should only ever interact with a next_cursor string. The integration layer handles translating that cursor into the provider-specific query parameters.
// A single recipe for paginating any resource
let cursor = null
do {
const url = `/unified/accounting/invoices?integrated_account_id=${id}` +
(cursor ? `&next_cursor=${cursor}` : '')
const { result, next_cursor } = await fetch(url).then(r => r.json())
await processBatch(result)
cursor = next_cursor
} while (cursor)5. Mapping Custom Fields and Polymorphic Resources
Enterprise accounting systems are heavily customized. A NetSuite instance will invariably contain custom fields, custom segments, and multi-subsidiary routing rules that break rigid, hardcoded data models. Every accounting system supports custom fields: QuickBooks has CustomField arrays, NetSuite uses custbody* and custcol* conventions, Xero has Tracking categories.
Declarative Mappings via JSONata
Your developer reference should explain that integration logic is treated as a data operation, not custom code. Instead of writing if (provider === 'netsuite') { ... }, use a transformation language like JSONata.
JSONata allows you to declaratively map complex, nested provider responses into your flat, unified schema. It handles conditionals, string manipulation, and array unrolling without requiring backend deployments.
/* Unified contact mapping for NetSuite - vendor type */
response.{
"id": $string(id),
"name": companyname ? companyname : (firstname & ' ' & lastname),
"email_address": email,
"phones": [{ "number": phone, "type": "primary" }],
"currency": currency_symbol,
"status": isinactive = 'F' ? 'ACTIVE' : 'INACTIVE',
"contact_type": "vendor",
"custom_fields": $sift(function($v, $k) { $contains($k, "custentity") })
}The Three-Level Override Hierarchy
To handle extreme customization, document the three-level override hierarchy. This is how you support enterprise customers without forking your codebase:
| Level | Stored On | Use Case |
|---|---|---|
| Platform | Base mapping | Default behavior that works for 90% of accounts. |
| Environment | Per-environment override | Customer-specific defaults applied to a workspace (e.g., staging vs production). |
| Account | Per-connected-account override | One enterprise customer with unusual custom fields (e.g., mapping custbody_custom_department to department_id). |
For a detailed look at this pattern, see Per-Customer Data Model Customization Without Code: The 3-Level JSONata Architecture.
Polymorphic Resource Routing
Accounting APIs often split identical logical entities into separate endpoints. For example, NetSuite has separate endpoints for customer and vendor.
Your cookbook must define polymorphic routing. A developer requests a single unified endpoint with a routing parameter:
# Vendor contact
GET /unified/accounting/contacts?integrated_account_id=abc&contact_type=vendor
# Customer contact
GET /unified/accounting/contacts?integrated_account_id=abc&contact_type=customerUnder the hood, the platform evaluates a conditional resource config against the query and dynamically routes the request to the NetSuite vendor or customer endpoint, applying the specific JSONata mapping on the response. The developer only interacts with the unified Contacts resource.
Complex Query Construction (The NetSuite Example)
Advanced integrations require dynamic query construction. For NetSuite, REST endpoints are often insufficient. Your documentation should note that reads are typically executed via SuiteQL (NetSuite's SQL dialect) to handle multi-table JOINs.
For example, querying TaxRates in NetSuite might require a hybrid approach: using SuiteQL to fetch tax item IDs, then executing a fallback SOAP API request to retrieve the actual rate percentages, because SuiteQL does not expose full tax configurations. The unified API abstracts this entirely, returning a clean array of tax objects to the developer.
6. Primary Workflows: Order-to-Cash and Procure-to-Pay
An API reference is useless without workflow documentation. Developers need to know the exact sequence of operations to execute standard accounting processes. The cookbook's most valuable section is the workflow chapter that ties endpoints into business outcomes.
The Order-to-Cash Workflow
When a deal closes in your CRM or a cart checks out on your e-commerce platform, the system must record the revenue.
sequenceDiagram participant App as Your SaaS participant API as Unified Accounting API participant ERP as QuickBooks / Xero / NetSuite App->>API: POST /contacts (find or create customer) API->>ERP: native customer create ERP-->>API: customer_id App->>API: POST /invoices (with items + tax) API->>ERP: native invoice create ERP-->>API: invoice_id, total, balance App->>API: POST /payments (apply to invoice) API->>ERP: payment create + apply ERP-->>App: 200 OK, ledger synced
- Resolve the Contact: Query the
/contactsendpoint using the customer's email. If no record exists,POSTa new contact withcontact_type=customer. - Resolve the Items: Query the
/itemsendpoint to find the ledger ID for the product sold. - Create the Invoice:
POSTto/invoiceswith the Contact ID, Item IDs, quantities, and amounts. The API translates this into the provider's specific line-item structure. - Apply the Payment: Once the credit card clears,
POSTto/paymentsreferencing the newly created Invoice ID to close the balance.
// Create an invoice through the unified API
const invoice = await truto.unified.accounting.invoices.create({
integrated_account_id,
data: {
contact: { id: customerId },
issue_date: '2026-05-19',
due_date: '2026-06-18',
currency: 'USD',
line_items: [{
item: { id: itemId },
quantity: 2,
unit_price: 499.00,
tax_rate: { id: taxRateId },
}],
},
})The Procure-to-Pay Workflow
For spend management or AP automation platforms, the flow is reversed.
- Create the Purchase Order:
POSTto/purchase_orderswith the Vendor ID and requested items. This encumbers the funds in the ledger. - Receive Goods: Update the PO status to received.
- Create the Bill/Expense:
POSTto/expensesor/vendor_creditsto record the actual liability. - Settle the Payable: Record the outbound bank transfer against the expense.
Typical Write Latency
An honest cookbook documents what "normal" looks like so engineers can tell when something is off. Expect roughly these end-to-end latencies for a single-record create through a unified API layer (including mapping overhead, which is usually well under 100ms):
| Operation | QuickBooks Online | Xero | NetSuite |
|---|---|---|---|
| Create invoice (5 line items) | 400-900ms | 600-1,500ms | 1,200-3,000ms |
| Create contact | 300-700ms | 400-900ms | 800-2,000ms |
| Get record by ID | 200-500ms | 300-700ms | 400-1,200ms (SuiteQL) |
NetSuite writes are heavier because the REST record API validates against a large schema and often triggers server-side SuiteScript. Xero's p95 can jump during their nightly maintenance windows. QuickBooks is generally the fastest, but its eventual consistency on writes means a subsequent read of a just-created record may return stale data for a few hundred milliseconds.
Idempotency and Failure Recovery
Network partitions happen. If your application sends a POST /invoices request and the connection drops before the response arrives, the invoice might exist in QuickBooks, but your database doesn't know the ID.
Your cookbook must dictate idempotency practices. Developers should append custom reference IDs (e.g., your internal database UUID) to an external_id field that maps to the provider's idempotency key, memo, or a custom field. Before retrying a timed-out creation request, the application should query the ledger for that reference ID to prevent duplicate billing.
Honest trade-off: A unified API does not eliminate provider weirdness. It compresses it. NetSuite SuiteQL JOINs, Xero's lack of true PATCH semantics, and QuickBooks' eventual consistency on writes are still real. The cookbook should document where the unified abstraction is leaky so engineers do not waste an afternoon debugging a problem the abstraction was never going to hide.
7. Pricing & Purchasing Considerations: Why Your Integration Platform Bill Spikes
Your cookbook documents endpoints, mappings, and auth flows. But if the CFO shows up asking why the integration platform invoice tripled last quarter, none of that matters until you can explain the cost model. This section is a one-page reference for product managers, finance leads, and engineers to diagnose billing spikes and decide whether the fix is architectural or contractual.
The Four Vendor Pricing Models You Will Encounter
Integration platforms charge in fundamentally different ways, and the billing model determines which operational behaviors are expensive. Here is a crosswalk:
| Pricing Model | What Gets Metered | Bill Spike Trigger | Who Feels It First |
|---|---|---|---|
| Per-connection / linked account | Each customer OAuth connection is a billable unit. Connect QuickBooks = 1 unit. Add Xero = 2 units. | Customer growth. Every new customer who connects their ledger increases the bill linearly. | Sales/CS teams onboarding new accounts. |
| Per-API-call | Every HTTP request to the unified API counts against a monthly allocation or per-call rate. | Polling frequency × connections × entities. A 5-minute sync cadence across 200 customers blows through allocations fast. | Engineering teams setting sync intervals. |
| Credit / compute-time | Billed by execution duration, data volume, or "credits" consumed per operation. Complex transforms and large payloads cost more credits. | Heavy data migrations, initial backfills, or large multi-subsidiary NetSuite syncs that consume disproportionate compute. | Data engineering during onboarding sprints. |
| Flat / per-customer | A fixed fee per end-customer regardless of connections, calls, or compute. Adding more integrations per customer does not change cost. | Customer count growth only. No spike from usage patterns. | Predictable - finance can forecast from the customer pipeline. |
Key insight: Per-call and credit-based models penalize exactly the behavior you want from a healthy integration - frequent syncs, complete data pulls, and broad adoption across your customer base. If your platform charges per call, every engineering decision about sync frequency becomes a finance decision too.
Worked Examples: How Small Decisions Create Big Bills
These examples show how sync cadence, connection count, and data volume interact to drive costs under different models.
Example 1: Polling cadence under per-call pricing
You have 200 customers, each with one accounting connection. You sync 4 entities (Invoices, Payments, Contacts, Items) every 15 minutes.
- Calls per customer per hour: 4 entities × 4 syncs = 16
- Calls per customer per day: 16 × 24 = 384
- Calls per month (all customers): 384 × 30 × 200 = 2,304,000 API calls
If your vendor charges $0.01 per call, that is $23,040/month. Switch to a 1-hour cadence and the bill drops to $5,760. Switch to webhooks for Invoices and Payments (reducing polling to 2 entities) at a 1-hour cadence and you are at $2,880.
Example 2: Connection growth under per-connection pricing
You support QuickBooks and Xero. Your 200 customers each connect one ledger - that is 200 billable linked accounts. Now you add NetSuite support. 40 enterprise customers connect NetSuite alongside their existing QuickBooks. Your linked account count jumps to 240 - a 20% cost increase with zero new customers.
Example 3: Backfill spike under credit-based pricing
A new enterprise customer connects their NetSuite instance with 3 years of historical data. The initial backfill pulls 50,000 invoices, 120,000 journal entries, and 30,000 contacts. Under a credit model where each record costs 0.5 credits and credits are $0.005 each, this single onboarding event costs $500 in credits - the same as 100 small customers running steady-state syncs for a month.
Troubleshooting Checklist: When the Bill Spikes
When your integration platform invoice looks wrong, walk through this checklist with your finance and product teams before escalating to the vendor:
- Identify the billing unit. Is your vendor charging per connection, per API call, per record, or per compute-time credit? Pull the contract and match it to the invoice line items.
- Count your connections. How many active linked accounts exist? Are sandbox, staging, and production connections all being billed? Disconnect test accounts and zombie connections that are no longer active.
- Audit sync frequency. What is the polling interval for each entity? Multiply:
connections × entities × (polls per day) × 30 = monthly call volume. Compare this number to your invoice. - Check for backfill events. Did a new customer connect with a large historical dataset? Initial syncs can consume 10-50x the steady-state volume in a single billing period.
- Look for retry amplification. If upstream APIs are returning errors (429s, 500s) and your application layer retries aggressively, you may be doubling or tripling your actual call count. Check error logs for retry storms.
- Verify entity scope. Are you syncing entities you do not use? Pulling
Attachments,Budgets, orRepeatingTransactionswhen your product only needsInvoicesandPaymentswastes quota. - Review pagination overhead. Large result sets that return 10 items per page generate far more API calls than those returning 100 or 200. Check whether your page size is optimized.
Architectural Fixes vs. Pricing Negotiation
Not every billing spike requires a vendor call. Some are engineering problems. Others are contract problems. Use this framework to decide:
Fix the architecture when:
- Your sync cadence is more aggressive than your product actually needs. Dropping from 5-minute to 30-minute polls is a code change, not a negotiation.
- You are polling when the provider supports webhooks. QuickBooks and Xero both support webhook notifications for key entities. Use them to replace polling for high-frequency changes.
- Retry storms are inflating call counts. Implement proper exponential backoff (see Section 4) and circuit breakers to prevent cascading retries.
- You are syncing entities or historical ranges you do not need. Scope your sync to only the resources your product consumes and limit initial backfills to a reasonable time window (e.g., 12 months instead of "all time").
Negotiate pricing when:
- Your call volume is growing linearly with customer count, and your per-unit cost is not decreasing. Volume discounts or committed-use tiers should reduce the effective rate.
- Your vendor charges per connection and your customers connect multiple integrations. A per-customer model (where one customer connecting QuickBooks + Xero + NetSuite counts as one billable unit) eliminates the multi-connection penalty.
- Backfill costs are unpredictable and spiky. Ask for a separate backfill rate or a one-time onboarding allocation that does not count against your monthly quota.
- You have outgrown usage-based pricing entirely. If your integration costs are more than 5-8% of the ARR those integrations help retain, it is time to evaluate flat-rate or per-customer models where costs scale with your revenue, not your API traffic.
The real question: Does your pricing model punish healthy product behavior? If adding a new sync entity, connecting a second ledger, or onboarding a large customer causes a billing spike, the pricing model is fighting your product goals. The best integration pricing aligns cost with customer value - not with the number of HTTP requests your background workers make.
8. Recommended Hard-Case Tests for Evaluation
When you are evaluating a unified API for accounting software like QuickBooks, Xero, and NetSuite, do not rely on the vendor's marketing site or its integration matrix. Every integration platform claims support for these three. What separates a real unified API from a thin proxy is how it behaves on the hard edges. Run these tests against a sandbox connection for each target provider during procurement. A PM or sales engineer can complete most of them in under a day.
Authentication and Reconnection
- Revoke from the customer side. Log into a sandbox QuickBooks or Xero account and disconnect the app. Verify the platform detects the revocation, emits a
needs_reauthevent, and pauses sync jobs automatically. - Expire a token mid-request. Force an access token to expire, then fire a call. Verify the platform refreshes transparently without surfacing a 401 to your app.
- Concurrent refresh storm. Trigger 20 parallel calls when a token is 10 seconds from expiry. Verify the platform serializes refresh attempts and does not produce
invalid_granterrors from stale rotated tokens. - NetSuite role change. In a NetSuite sandbox, remove a permission from the integration user's role and retry a call that depends on it. Verify the error message identifies the missing permission, not just a generic 403.
Data Model Completeness
- Rich invoice creation. Create an invoice with 20 line items, per-line tax rates, tracking categories, a currency other than the base currency, and a custom field. Verify the record round-trips correctly through create + read in all three ledgers.
- NetSuite custom fields. Read a NetSuite record populated with
custbody*andcustcol*custom fields. Verify the custom data is accessible viaremote_dataor a per-account override without vendor code changes. - Deleted or merged records. Query a QuickBooks customer that has been merged into another customer. Verify the platform returns a consistent, documented error instead of a stack trace.
- Multi-subsidiary NetSuite. Connect a NetSuite OneWorld sandbox with multiple subsidiaries. Verify the unified
CompanyInfoandContactsresources return correct subsidiary data without account-specific configuration.
Rate Limits and Error Handling
- Burst 200 requests in 10 seconds against a sandbox QuickBooks connection. Verify the platform surfaces normalized
RateLimit-*headers and returns 429s cleanly rather than silently retrying (which would mask the problem in production). - 429 during a write. Force a 429 mid-
POST. Verify the platform does not automatically retry on write operations, since blind retries on non-idempotent calls cause duplicate invoices. - Invalid tax rate ID. Send a create request with a bogus
tax_rate.id. Verify the error identifies the offending field, and that the error format is identical across QuickBooks, Xero, and NetSuite. - NetSuite concurrency ceiling. Fire more parallel requests than your service tier's concurrency limit allows. Verify the platform queues or surfaces the rejection instead of crashing.
Write Latency and Idempotency
- Measure p50/p95 latency for creating 100 invoices in each provider. Compare against the reference numbers in Section 6. If a vendor's overhead exceeds 200ms on top of the upstream latency, they are doing too much per call.
- Timeout during create. Kill the network mid-
POST /invoices. Reconnect and retry with the sameexternal_id. Verify no duplicate invoice is created and the platform surfaces the original record. - Bulk create. Push 500 invoices in rapid succession. Verify the platform respects upstream batch endpoints (QuickBooks Batch API) rather than firing 500 individual calls.
Webhooks and Change Data Capture
- Direct update in the ledger. Update an invoice directly in the QuickBooks UI. Verify the platform delivers a normalized webhook within a reasonable window (QuickBooks batches webhook delivery, so expect delays of several minutes, not seconds).
- Webhook subscription health. Delete the platform's webhook subscription from the provider's side. Verify the platform detects the missing subscription and re-registers or surfaces the failure.
- Missed event recovery. Pause the platform's webhook ingestion for an hour, then resume. Verify the platform can backfill missed changes via change data capture (QuickBooks CDC API, Xero
If-Modified-Since).
Historical Backfill
- 3-year backfill. Trigger an initial sync against a sandbox with 3+ years of transactions. Measure how long it takes, whether the platform respects upstream rate limits, and (critically) how the pricing model bills for the initial pull.
- Resumable backfill. Kill the backfill process midway. Verify it resumes from the last checkpoint instead of restarting from zero.
If a vendor cannot demonstrate all of the above in a technical evaluation call, they are not ready for your production traffic. The best time to discover a leaky abstraction is during procurement, not two quarters into an enterprise rollout.
Scaling Integrations as Data Operations
Building a developer cookbook for unified accounting API usage forces your engineering team to treat integrations as infrastructure rather than bespoke product features. The biggest payoff from organizing your accounting integrations around a cookbook is not documentation hygiene. It is the architectural shift toward treating integrations as data, not code.
When your mappings live as JSONata expressions in a database (or in a YAML file checked into your config repo), three things become possible:
- Adding a new ERP is a configuration change, not a deploy. Sage Intacct support is a new mapping file, not 2,000 lines of TypeScript.
- Per-customer customization stops being a fork. Enterprise customers with unique custom fields get an account-level override. No special branch of your codebase.
- Breaking API changes ship as hot patches. When QuickBooks deprecates a field, you update one JSONata expression and every customer benefits within seconds.
For a deeper look at this pattern, see Zero Integration-Specific Code: How to Ship API Connectors as Data-Only Operations.
Next Steps
- Audit your current accounting integration docs. Are they endpoint references or workflow recipes? Are mappings code or data?
- Pick three workflows to document first. Order-to-cash, procure-to-pay, and bank reconciliation cover 80% of use cases.
- Build the failure mode catalog. For each integration, document 429 behavior, token refresh failures, and custom field edge cases.
- Make the cookbook executable. Include runnable code snippets and Postman collections, not just prose.
When your architecture abstracts away the differences between Xero, QuickBooks, and NetSuite, your developers can focus on shipping core product value. You stop reading third-party API documentation and start executing reliable financial workflows at scale.
FAQ
- What should a developer cookbook for a unified accounting API include?
- At minimum: a canonical data model organized into ledger, AR, AP, stakeholders, and reconciliation domains; authentication flows per provider; rate limit and pagination contracts; custom field mapping patterns using JSONata; and end-to-end workflow recipes for order-to-cash and procure-to-pay.
- How do you handle rate limits across different accounting APIs?
- Normalize upstream rate limit information into standard IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) and pass HTTP 429 errors directly to the caller, allowing the consuming application to dictate the exponential backoff strategy.
- How do you handle custom fields in NetSuite or QuickBooks?
- Use a declarative transformation language like JSONata combined with a multi-level override hierarchy (Platform, Environment, Account), allowing per-account schema customization without altering core application code.
- What is a polymorphic resource in a unified accounting API?
- A polymorphic resource is a single unified endpoint that dispatches to different native endpoints based on a query parameter. For example, a unified contacts endpoint can route to either the NetSuite vendor or customer record type depending on a contact_type parameter.
- Why is a unified accounting API better than point-to-point integrations?
- It abstracts provider-specific authentication, pagination, and data models into a single schema, treating integrations as data operations rather than maintaining dozens of isolated code paths.