Integrating with Xero and QuickBooks: A Developer's Guide to Accounting APIs
When I started building BalancingIQ, I thought integrating with accounting software would be straightforward. "Just hit their REST API," I told myself. "OAuth, grab some data, done in a week."
Three weeks later, I was still debugging OAuth token refresh loops, figuring out why invoice line items didn't match between Xero and QuickBooks, and learning the hard way that "accounting data" is far messier than it looks in a demo.
Here's what I wish someone had told me before I started: accounting APIs are deceptively complex. They're not like Stripe or Twilio where the data model is clean and the docs are excellent. They're built on decades of accounting principles, with edge cases you've never heard of, and they assume you already know how double-entry bookkeeping works.
This guide is what I wish I'd read before integrating Xero and QuickBooks into production. It covers the real challenges, the gotchas nobody tells you, and the patterns that actually work at scale.
Why Accounting APIs Are Harder Than They Look
If you've integrated with Stripe or Shopify, you might think accounting APIs will be similar. They're not. Here's why:
1. The Data Models Are Complex and Domain-Specific
Accounting isn't just "revenue in, expenses out." It's:
- Invoices with line items, tax rates, discounts, partial payments
- Bills (accounts payable) that track what you owe
- Bank transactions that need to be reconciled against invoices/bills
- Chart of Accounts (COA) — the backbone of all financial data
- Tracking categories (departments, projects, locations)
- Journal entries for manual adjustments
- Multi-currency with exchange rates and gains/losses
And that's just the basics. Every business organizes their chart of accounts differently, uses different tax codes, and has custom workflows.
2. Xero and QuickBooks Have Different Philosophies
You can't just write one integration layer and expect it to work for both:
- Xero is more flexible, developer-friendly, and "modern" in API design
- QuickBooks Online (QBO) is more rigid, enterprise-focused, and has quirks from legacy desktop versions
- Field names differ: Xero uses
LineAmount, QBO usesAmount - Date formats differ: Xero uses ISO strings, QBO uses YYYY-MM-DD
- IDs differ: Xero uses GUIDs, QBO uses numeric IDs
You need an abstraction layer, or you'll end up with spaghetti code full of if/else branches.
3. OAuth Is Just the Beginning of Your Pain
Both platforms use OAuth 2.0, but the implementations differ:
- Xero tokens expire in 30 minutes; refresh tokens last 60 days
- QBO tokens expire in 1 hour; refresh tokens last 100 days
- Both require you to refresh tokens before they expire, or users have to re-auth
- Refresh token rotation: each refresh gives you a new refresh token; the old one is invalidated
If you don't handle token refresh correctly, users will randomly get logged out, and you'll spend days debugging why.
OAuth Implementation: The Right Way
Let's start with OAuth, because if you get this wrong, nothing else matters.
The OAuth Flow (High Level)
- User clicks "Connect Xero" or "Connect QuickBooks" in your app
- You redirect them to the provider's OAuth consent screen
- User authorizes your app and is redirected back with an authorization code
- You exchange the code for an access token and refresh token
- You store both tokens encrypted in your database
- You use the access token to make API calls
- Before the access token expires, you refresh it using the refresh token
- Repeat step 7 forever (or until user revokes access)
Critical Implementation Details
1. Encrypt tokens before storing them
These tokens give full access to financial data. If your database is compromised, encrypted tokens are useless to attackers.
In BalancingIQ, we use AWS KMS with unique encryption contexts per organization:
// Encrypt before storing
const encrypted = await kms.encrypt({
KeyId: process.env.KMS_KEY_ID,
Plaintext: Buffer.from(JSON.stringify(tokens)),
EncryptionContext: { orgId: organization.id }
});
await db.put({
orgId: organization.id,
encryptedTokens: encrypted.CiphertextBlob,
provider: 'xero', // or 'quickbooks'
expiresAt: new Date(Date.now() + 30 * 60 * 1000) // 30 min
});2. Refresh tokens proactively, not reactively
Don't wait until you get a 401 error to refresh. Set up a scheduled job (e.g., Lambda cron) that runs every 15 minutes and refreshes any tokens expiring in the next 10 minutes.
// Scheduled Lambda that runs every 15 minutes
const tokensExpiringSoon = await db.query({
IndexName: 'expiresAt-index',
KeyConditionExpression: 'expiresAt < :soon',
ExpressionAttributeValues: {
':soon': Date.now() + 10 * 60 * 1000 // 10 min buffer
}
});
for (const record of tokensExpiringSoon) {
await refreshTokens(record.orgId, record.provider);
}3. Handle refresh token rotation correctly
When you refresh, you get a new refresh token. The old one is invalidated immediately. If two processes try to refresh at the same time, one will fail and you'll lose access.
Solution: Use a lock (DynamoDB conditional writes, Redis lock, etc.) to ensure only one refresh happens at a time per organization.
Data Models: Abstracting Xero and QuickBooks
You need an abstraction layer that normalizes the differences between Xero and QuickBooks. Here's the pattern that worked for us:
Define a Common Interface
// Common interface for invoices
interface Invoice {
id: string;
number: string;
date: Date;
dueDate: Date;
customer: {
id: string;
name: string;
};
lineItems: {
description: string;
quantity: number;
unitPrice: number;
total: number;
accountCode: string;
}[];
subtotal: number;
tax: number;
total: number;
amountDue: number;
status: 'draft' | 'submitted' | 'paid' | 'voided';
}Implement Provider-Specific Adapters
class XeroAdapter {
async getInvoices(orgId: string): Promise<Invoice[]> {
const raw = await xeroClient.invoices.list();
return raw.map(this.normalizeInvoice);
}
private normalizeInvoice(xeroInvoice): Invoice {
return {
id: xeroInvoice.InvoiceID,
number: xeroInvoice.InvoiceNumber,
date: new Date(xeroInvoice.Date),
dueDate: new Date(xeroInvoice.DueDate),
customer: {
id: xeroInvoice.Contact.ContactID,
name: xeroInvoice.Contact.Name
},
// ... map other fields
status: this.normalizeStatus(xeroInvoice.Status)
};
}
}
class QuickBooksAdapter {
async getInvoices(orgId: string): Promise<Invoice[]> {
const raw = await qboClient.query('SELECT * FROM Invoice');
return raw.map(this.normalizeInvoice);
}
private normalizeInvoice(qboInvoice): Invoice {
return {
id: qboInvoice.Id,
number: qboInvoice.DocNumber,
date: new Date(qboInvoice.TxnDate),
dueDate: new Date(qboInvoice.DueDate),
customer: {
id: qboInvoice.CustomerRef.value,
name: qboInvoice.CustomerRef.name
},
// ... map other fields
status: this.normalizeStatus(qboInvoice.Status)
};
}
}Use a Factory Pattern
function getAccountingAdapter(provider: 'xero' | 'quickbooks') {
switch (provider) {
case 'xero':
return new XeroAdapter();
case 'quickbooks':
return new QuickBooksAdapter();
default:
throw new Error(`Unknown provider: ${provider}`);
}
}
// Usage
const adapter = getAccountingAdapter(org.provider);
const invoices = await adapter.getInvoices(org.id);Rate Limits and Pagination
Rate Limits
- Xero: 60 requests per minute per tenant, 5,000 per day
- QuickBooks: 500 requests per minute per app (shared across all users)
Both return 429 Too Many Requests when you exceed limits. Implement exponential backoff with jitter:
async function fetchWithRetry(fn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (error.statusCode === 429 && i < maxRetries - 1) {
const delay = Math.min(1000 * 2 ** i + Math.random() * 1000, 10000);
await sleep(delay);
continue;
}
throw error;
}
}
}Pagination
Both APIs paginate results, but differently:
- Xero: Uses
pageparameter (1-indexed) - QuickBooks: Uses
startPositionandmaxResults
Real-World Gotchas and Solutions
1. Modified Data Detection
Don't re-fetch all invoices every time. Use the If-Modified-Since header (Xero) or ModifiedAfter query param (QBO) to only fetch changes since your last sync.
2. Handling Deleted Records
Neither API tells you when records are deleted. You need to periodically do a full sync and mark missing records as deleted in your system.
3. Multi-Currency Complications
If a business uses multiple currencies, every amount needs a currency code. Exchange rates change daily. Don't try to convert everything to USD — store amounts in their original currency.
4. Chart of Accounts Variations
Every business structures their COA differently. You can't hardcode "Revenue" is account code 4000. Instead, let users map their accounts to your categories, or use heuristics based on account types.
5. Webhooks Are Unreliable
Both platforms support webhooks, but they're not 100% reliable. Use them for real-time updates, but always have a background job that syncs periodically (hourly or daily) to catch missed events.
Testing Strategy
Testing accounting integrations is hard because:
- You need real Xero/QBO accounts to test against
- Test data needs to be realistic (invoices with line items, tax, discounts, etc.)
- Both platforms have sandbox environments, but they're limited
Our approach:
- Create a test Xero organization and QBO sandbox with realistic data
- Mock the API responses for unit tests using fixtures
- Run integration tests nightly against the test accounts
- Use feature flags to test new sync logic in production with opt-in customers
Performance and Cost Optimization
1. Cache Aggressively
API calls are slow and count against rate limits. Cache everything you can:
- Chart of Accounts (changes rarely)
- Tax rates (changes rarely)
- Customer/vendor lists (cache for 1 hour, refresh on demand)
- Invoices (cache for 15 minutes, invalidate when webhook fires)
2. Batch Operations
Instead of fetching data on-demand when a user loads a page, sync data in the background and serve from your database. This is faster and more reliable.
3. Incremental Syncing
Only fetch data that's changed since the last sync. Store lastSyncedAt per organization and use it to filter queries.
Security Best Practices
- Encrypt tokens at rest using AWS KMS or equivalent
- Use HTTPS everywhere — never send tokens over HTTP
- Implement PKCE for OAuth to prevent authorization code interception
- Scope tokens appropriately — request only the permissions you need
- Log access — who accessed what data when (for audit trails)
- Monitor for revoked tokens — handle 401s gracefully and notify users
Building a FinTech product? I've built production integrations with Xero and QuickBooks for BalancingIQ and would love to share more detailed patterns and code. Reach out at adamdugan6@gmail.com or connect with me on LinkedIn.