End-to-End Developer Tutorial: Integrating the Brex API with QBO, Xero, and NetSuite
A complete engineering tutorial on integrating the Brex API with QuickBooks Online, Xero, and NetSuite. Learn ledger mapping, idempotency, and rate limiting.
If you are building an integration to pull finalized transactions from Brex and push them into an accounting system, you are not just building an API connector. You are bridging a high-velocity, flat fintech API with the rigid, double-entry constraints of legacy financial ledgers.
If you're building expense management, AP automation, or corporate card sync features, the actual engineering problem isn't calling the Brex API. It's turning a firehose of card transactions into balanced, audit-ready journal entries inside QuickBooks Online, Xero, and NetSuite—three accounting systems with wildly different data models, authentication schemes, and write constraints.
This end-to-end tutorial covers the exact architectural patterns, ledger mapping logic, and rate limit handling required to build this pipeline in production without maintaining three separate codebases.
The demand for this specific data pipeline is accelerating. According to Mordor Intelligence, the AP automation market is projected to reach $12.46 billion by 2031, growing at a 12.44% CAGR, driven by cloud-native finance stacks and e-invoicing mandates. Furthermore, Gartner's 2025 AI in Finance Survey notes that 59% of finance leaders are actively using AI in their finance functions. This means your integration isn't just moving data anymore; it's feeding downstream AI agents that require clean, programmatically accessible spend data. Brute-forcing point-to-point connections is an unscalable approach.
The Architectural Challenge of Syncing Spend to Accounting
Why is syncing corporate spend to accounting software so difficult?
The short answer: Brex is a high-velocity, event-driven fintech API. QuickBooks Online (QBO), Xero, and NetSuite are rigid, double-entry ledgers with strict validation, dependency graphs, and per-tenant configuration. Syncing one to the other is a translation problem, not a simple proxy problem.
A corporate card swipe is a flat event: merchant, amount, currency, cardholder, and timestamp. An accounting system requires that event to be split into balanced debits and credits, tied to specific Chart of Accounts (CoA) nodes, vendors, and tax codes. To land a Brex transaction in an accounting system, you have to answer questions the source API doesn't care about:
- Which GL account does this merchant map to? (Meals? Software Subscriptions? Travel?)
- Which vendor record does the merchant string resolve to? Does one even exist yet?
- What tax code applies in this jurisdiction? Does the target ledger even track tax on card expenses?
- Which class, department, location, or subsidiary should this hit for reporting?
- Is this a reimbursable employee expense or a corporate liability? The journal shape is fundamentally different for each.
- What is the debit/credit pair that keeps the entry balanced?
Miss any of these and either the API call fails validation, or worse, it succeeds and quietly corrupts the customer's financial books.
The engineering pain compounds because of API fragmentation. Every accounting system handles journal entries differently. QBO uses strict REST constraints, Xero requires specific tax type strings and silently ignores unrecognized tracking categories, and NetSuite requires a mix of REST, SOAP, and custom scripts while rejecting transactions if the subsidiary doesn't match the currency.
To solve this, you need a middleware mapping layer. You cannot map Brex data directly to a QuickBooks schema without breaking your Xero integration later.
flowchart TD
A["Brex API<br>(Transactions, Expenses)"] -->|"JSON Payloads"| B["Your Normalization Layer<br>(Vendor Resolution, GL Mapping, Tax Codes)"]
B -->|"Unified Expense Model"| C["API Abstraction Layer"]
C -->|"QBO Schema (Purchase / JournalEntry)"| D["QuickBooks Online"]
C -->|"Xero Schema (BankTransaction / ManualJournal)"| E["Xero"]
C -->|"SuiteQL / RESTlet (ExpenseReport / VendorBill)"| F["Oracle NetSuite"]
D -->|"Chart of Accounts, Tax Rates"| B
E -->|"Tracking Categories, Contacts"| B
F -->|"Subsidiaries, Currencies"| B```
For a deeper look at the theory behind this architecture, review our [engineering guide to the Brex accounting stack](/how-to-integrate-the-brex-api-with-your-accounting-stack-2026-engineering-guide/).
## Step 1: Authenticating and Fetching Brex Transactions
The Brex API provides a `Transactions` endpoint designed specifically to surface real-time financial data and finalized transactions for external applications.
Brex uses OAuth 2.0 with bearer tokens for user-context integrations, and static API tokens for internal automations. For any multi-tenant B2B SaaS, you must implement the OAuth flow—static tokens do not survive an admin turnover. Your system must securely store short-lived access tokens and long-lived refresh tokens, refreshing them prior to expiration.
Once authenticated, you query the `/v2/transactions/card/primary` endpoint.
:::callout{type="warning"}
Do not sync pending transactions to an accounting ledger. Pending authorizations are noisy; they frequently change amounts (e.g., a restaurant tip being added days later) or drop off entirely. Always filter strictly for finalized transactions, which carry the settled merchant category code and final amount.
:::
Here is how you can quickly test fetching finalized card transactions using a simple `curl` command:
```bash
curl -X GET "https://platform.brexapis.com/v2/transactions/card/primary?limit=100" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)"In a production environment, you need to walk the full cursor chain to ingest historical data. Here is a robust JavaScript implementation using an async generator:
// Fetch finalized card transactions with cursor pagination
async function fetchBrexTransactions(accessToken, cursor = null) {
const url = new URL('https://platform.brexapis.com/v2/transactions/card/primary');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Idempotency-Key': crypto.randomUUID()
}
});
if (!res.ok) throw new Error(`Brex API ${res.status}: ${await res.text()}`);
return res.json(); // { items: [...], next_cursor: '...' }
}
// Walk the full cursor chain
async function* streamAllTransactions(token) {
let cursor = null;
do {
const page = await fetchBrexTransactions(token, cursor);
for (const tx of page.items) yield tx;
cursor = page.next_cursor;
} while (cursor);
}A few architectural gotchas that will bite you in production:
- Backfills are painful: Brex caps historical windows on some endpoints. Plan your first sync as a background job that can pause, checkpoint the cursor, and resume on failure.
- Cardholder identity is separate: The transaction payload gives you a user reference, but employee-to-vendor mapping in the ledger requires a second Users API call. Cache this aggressively.
- Currency is on the transaction, not the account: International cards can post USD, EUR, or local currency on the exact same account. Never assume the account's base currency.
Store the raw Brex JSON payload alongside your normalized version in your database. When a customer disputes a posted journal entry, you need the untouched source of truth for audit and reconciliation. Furthermore, Brex webhooks can be replayed against this raw data.
Step 2: Normalizing Data for the General Ledger
This is where 80% of your engineering time will go. A Brex transaction is not a journal entry—it is a flat fact that needs to be dressed up with references to entities that live inside the customer's specific accounting tenant.
The normalization layer needs to do four things: resolve the vendor, map the GL account, balance the entry, and attach dimensional metadata. To automate this, your application needs a mapping interface where the user defines which Brex categories map to which ERP accounts. This requires caching the target ERP's Chart of Accounts locally, as querying the ERP for the CoA on every transaction swipe will exhaust your rate limits immediately.
Here is the canonical shape you want to reach before touching any accounting API:
interface NormalizedExpense {
external_id: string; // Brex transaction ID (used as idempotency key)
posted_at: string; // ISO-8601 date
currency: string; // ISO 4217
total_amount: number; // Positive number in major units
vendor_ref: { id: string; name: string } | null;
lines: Array<{
gl_account_ref: { id: string };
amount: number;
tax_code_ref?: { id: string };
tracking?: { class_id?: string; department_id?: string };
memo?: string;
}>;
payment_account_ref: { id: string }; // The Brex clearing liability account
attachments?: Array<{ url: string; filename: string }>;
}Field Mapping Strategy
| Brex Field | Normalized Accounting Field | Target ERP Requirement |
|---|---|---|
amount.amount |
total_amount |
Must be converted to major units (e.g., cents to dollars) based on currency. |
merchant_name |
vendor_ref |
Requires a lookup against the ERP's vendor list. If missing, create a new vendor. |
settled_at |
posted_at |
Must be formatted to the specific ISO-8601 or YYYY-MM-DD format the ERP demands. |
category |
lines[0].gl_account_ref |
Requires user-defined mapping to a specific Chart of Accounts ID. |
Vendor resolution is the hardest part. Merchant strings from card networks are incredibly messy (e.g., SQ *COFFEE SHOP #42, AMZN Mktp US*3H4K2). You have three realistic strategies:
- Exact match on a normalized merchant name (strip prefixes, punctuation, store numbers).
- Fuzzy match with a similarity threshold against existing vendors in the ledger.
- Create-on-miss with an audit flag so the finance team can merge duplicates later.
GL account mapping should be driven by a customer-configurable rules engine, not hardcoded MCC lookups. A typical rule: if merchant_category = 5812 AND amount < 75 then GL = 'Meals - 50% Deductible'. Store rules as ordered predicates where the first match wins.
Balancing the entry requires knowing which side of the ledger the Brex account sits on. Accounting systems utilize double-entry bookkeeping. A $1,450 AWS charge on a Brex card requires two entries: a Credit to the Brex Credit Card liability account (increasing the liability), and a Debit to the Software Subscriptions expense account (increasing the expense). Reimbursements flip this logic entirely.
Dimensional metadata varies per ledger. QBO has classes and departments. Xero has tracking categories (max two per line). NetSuite has classes, departments, locations, subsidiaries, and custom segments. Model it as an open-ended dictionary and let the destination adapter pick what it needs.
If you are building MCP integrations to allow AI agents to handle this mapping, ensure the agent has access to the cached CoA and vendor lists via standardized tool calls.
Step 3: Handling Rate Limits and API Errors
When writing high volumes of transactions to accounting APIs, you will inevitably hit HTTP 429 (Too Many Requests) errors. Every accounting API throttles differently. QuickBooks Online limits you to 500 requests per minute per realm. Xero limits you to 60 requests per minute and 5000 per day per tenant. NetSuite's concurrency limits depend entirely on the customer's specific license tier and points-per-second, making it highly unpredictable.
Getting throttling wrong doesn't just slow you down—it will silently drop customer transactions on high-volume days. Handling these limits requires a strict queueing system with exponential backoff. The pattern that actually works in production looks like this:
async function callWithBackoff(fn, { maxRetries = 5 } = {}) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fn();
if (res.status !== 429 && res.status < 500) return res;
// Prefer standardized IETF headers when the platform normalizes them
const reset = res.headers.get('ratelimit-reset');
const retryAfter = res.headers.get('retry-after');
const waitMs = reset
? Number(reset) * 1000
: retryAfter
? Number(retryAfter) * 1000
: Math.min(2 ** attempt * 250 + Math.random() * 250, 30_000);
if (attempt === maxRetries) throw new Error(`Exhausted retries: ${res.status}`);
await new Promise(r => setTimeout(r, waitMs));
}
}If you are using a unified API platform like Truto, it is critical to understand how rate limits are surfaced. Truto does not retry, throttle, or apply backoff on rate limit errors automatically. When an upstream API returns HTTP 429, Truto passes that error straight through to the caller.
However, Truto normalizes the upstream rate limit information into standardized headers per the IETF specification:
ratelimit-limit: The total request quota.ratelimit-remaining: The remaining quota.ratelimit-reset: The time window reset timestamp.
This is a deliberate architectural choice. Retry logic belongs in the caller because only the caller knows whether the operation is safe to retry, what the SLA on the end-user experience is, and whether an equivalent request is already queued upstream. An opaque middleware that hides 429s behind blind retries makes your system less debuggable and can multiply upstream load during degradation. You can inspect the ratelimit-reset header and pause your worker queue exactly until the window clears.
Additionally, you must implement idempotency. When writing an expense to an ERP, you must generate a unique identifier (often a hash of the Brex transaction ID) and pass it as the idempotency key. QBO, Xero, and NetSuite all have retry semantics that can produce duplicate journals if you re-send without deduplication logic. Cleaning duplicates out of a customer's ledger is a massive support nightmare.
Step 4: Writing to QBO, Xero, and NetSuite
Once the data is normalized and your retry logic is bulletproof, the record must be formatted for the specific target API. This is where point-to-point integrations usually collapse under their own weight, as the same normalized expense record has to be reshaped three different ways (a challenge we explore further in our developer guide to integrating Brex with Xero and QuickBooks).
Writing to QuickBooks Online (QBO)
QuickBooks Online utilizes a strict REST API with a mandatory minorversion query parameter that dictates schema behavior. For card charges, you have two viable options: a Purchase (recommended for cash-basis simplicity) or a JournalEntry (required if your customer needs precise debit/credit control). Purchases auto-generate the balancing lines; JournalEntries force you to send both sides.
To write a Brex transaction as a QBO Purchase, you must construct a payload that references the internal QBO IDs for the accounts and vendors:
{
"AccountRef": {
"value": "85",
"name": "Brex Credit Card"
},
"PaymentType": "CreditCard",
"EntityRef": {
"value": "120",
"type": "Vendor"
},
"TxnDate": "2026-10-15",
"CurrencyRef": {
"value": "USD"
},
"Line": [
{
"Amount": 1450.00,
"DetailType": "AccountBasedExpenseLineDetail",
"AccountBasedExpenseLineDetail": {
"AccountRef": {
"value": "42"
},
"TaxCodeRef": {
"value": "NON"
}
}
}
]
}QBO is unforgiving. Line items must reference existing AccountRef IDs (you cannot create a GL account inline). If AccountRef 85 is not actually configured as a Credit Card liability account in that specific customer's ledger, the API will reject the entire payload with a cryptic validation error (often a 6000 series error code). Always parse the Detail field on QBO errors.
Writing to Xero
Xero handles expenses differently. They expose BankTransaction endpoints for card feed-style entries and ManualJournal endpoints for full DR/CR control, especially useful for multi-currency transactions.
Xero requires strict adherence to their TaxType configurations. You cannot simply pass a tax percentage; you must pass the exact tax code string configured in the user's Xero environment.
{
"ManualJournals": [
{
"Narration": "Brex - AWS Cloud Services",
"Date": "2026-10-15",
"Status": "POSTED",
"JournalLines": [
{
"Description": "Software Subscriptions",
"AccountCode": "400",
"TaxType": "NONE",
"LineAmount": 1450.00
},
{
"Description": "Brex Credit Card",
"AccountCode": "800",
"LineAmount": -1450.00
}
]
}
]
}Notice that Xero uses a negative LineAmount to represent the credit side of the journal entry, whereas QBO handles the debit/credit logic implicitly based on the account type. Furthermore, tracking categories are limited to two per line in Xero, and unrecognized ones are silently dropped rather than rejected—a massive footgun for reconciliation.
Writing to Oracle NetSuite
NetSuite is an entirely different beast, designed for enterprise resource planning. A simple expense often requires linking to subsidiaries, departments, classes, locations, and custom segments.
The ExpenseReport and VendorBill records are the natural landing spots, but writing to them requires orchestrating across three API surfaces: SuiteTalk REST, RESTlets (SuiteScript), and legacy SOAP. While REST is the modern default, NetSuite's architecture still relies on RESTlets for custom logic and SOAP for specific legacy operations (like fetching detailed sales tax rate configurations, which SuiteQL doesn't fully expose).
If you are building this from scratch, you must detect the customer's NetSuite edition at runtime. Is it OneWorld (multi-subsidiary)? Is the Multiple Currencies feature enabled? If so, your payload must include the subsidiary and currency fields. If those features are disabled, including those fields will throw a fatal error.
{
"subsidiary": {
"id": "3"
},
"currency": {
"id": "1"
},
"tranDate": "2026-10-15",
"memo": "Brex - AWS Cloud Services",
"line": {
"items": [
{
"account": {
"id": "112"
},
"debit": 1450.00,
"entity": {
"id": "459"
}
},
{
"account": {
"id": "205"
},
"credit": 1450.00
}
]
}
}To read data efficiently from NetSuite to populate your mapping UI, you should avoid the standard REST record API. Instead, use SuiteQL (NetSuite's SQL-like query language), which allows for complex multi-table JOINs and significantly better performance. For a detailed breakdown of handling NetSuite's API quirks, see our NetSuite API tutorial.
Why Point-to-Point Integrations Fail at Scale
Building the data transformation logic shown above for one Brex-to-QBO pipeline is a two-week project. Building Brex to QBO, Xero, and NetSuite is a two-quarter project. Maintaining all three across schema drift, deprecated endpoints, new tax jurisdictions, and per-tenant edge cases becomes the reason your team can't ship anything else.
This is why modern B2B SaaS platforms are abandoning point-to-point integrations in favor of unified APIs. A unified API architecture inverts the ownership model. Instead of maintaining three destination adapters plus the normalization layer plus the auth flows plus the retry logic, you write one integration against one normalized schema.
Truto's architecture utilizes zero integration-specific code. This means developers do not have to maintain separate code paths for QBO, Xero, and NetSuite. You write to a single, normalized Expenses endpoint, and the unified API handles the platform-specific translation:
// Same call, different destinations. integrated_account_id routes the request.
await fetch('https://api.truto.one/unified/accounting/expenses', {
method: 'POST',
headers: {
'Authorization': `Bearer ${TRUTO_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
integrated_account_id: customerAccountId, // qbo, xero, or netsuite
remote_data: normalizedExpense
})
});Under the hood, Truto utilizes polymorphic resource routing and feature-adaptive queries that dynamically adapt to complex ERPs like NetSuite. The platform automatically detects OneWorld vs. standard NetSuite editions and includes or excludes subsidiary and currency joins automatically, without requiring custom branch logic from your team.
By decoupling your core application logic from the quirks of legacy accounting APIs, you can ship integrations faster, eliminate maintenance overhead, and focus on building the actual spend management features your customers are paying for. For more architectural strategies on standardizing financial data pipelines, read our guide on architecting QuickBooks, Xero, and NetSuite integrations with unified APIs.
Where to Take This Next
Start by shipping a single destination end-to-end (QBO is the most forgiving). Instrument the normalization layer with structured logs so you can measure vendor match rates and GL mapping coverage on real customer data. Then, when you're ready to scale from one destination to three without tripling your maintenance burden, evaluate whether the mapping layer belongs in your codebase or behind a unified API.
The writing patterns for QBO, Xero, and NetSuite do not get simpler over time. Tax jurisdictions expand, e-invoicing mandates go live, and every ERP ships breaking changes on its own cadence. Own the customer-facing product; borrow the plumbing.
FAQ
- How do I integrate the Brex API with accounting software like QuickBooks, Xero, and NetSuite?
- You need four layers: an OAuth-based Brex client that pulls finalized transactions, a normalization layer that resolves vendors and maps GL accounts, an idempotent write pipeline, and destination adapters that reshape the normalized expense for each ledger's schema. A unified accounting API collapses the last two layers into a single call.
- What is the hardest part of syncing Brex transactions to a general ledger?
- Vendor resolution and GL account mapping. Card network merchant strings are messy, and every customer has their own chart of accounts and categorization rules. Getting these wrong doesn't fail loudly—it silently corrupts the customer's books, which is worse than a 500 error.
- Should I handle rate limit retries in my integration or delegate them?
- Handle them yourself. Retry logic depends on request idempotency, user-facing SLA, and knowledge of your own queue state. The right primitive is a transparent 429 pass-through with standardized ratelimit-limit, ratelimit-remaining, and ratelimit-reset headers so your backoff logic is uniform across providers.
- Why is integrating with NetSuite harder than QuickBooks or Xero?
- NetSuite requires orchestrating across three API surfaces (SuiteTalk REST, RESTlets, and legacy SOAP), and its data model varies by edition. OneWorld tenants need subsidiary and currency joins that single-instance tenants don't have. Furthermore, sales tax rate details often still require SOAP because SuiteQL doesn't expose the full configuration.