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:

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:

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:

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)

  1. User clicks "Connect Xero" or "Connect QuickBooks" in your app
  2. You redirect them to the provider's OAuth consent screen
  3. User authorizes your app and is redirected back with an authorization code
  4. You exchange the code for an access token and refresh token
  5. You store both tokens encrypted in your database
  6. You use the access token to make API calls
  7. Before the access token expires, you refresh it using the refresh token
  8. 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

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:

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:

Our approach:

Performance and Cost Optimization

1. Cache Aggressively

API calls are slow and count against rate limits. Cache everything you can:

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

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.