You've built a SaaS for AU contractors, agencies, or studios. Your billing module generates invoices, quotes, and expenses. But the customer doesn't check your app's reporting — they check Xero or MYOB because that's where their accountant works. If your invoices aren't synced there, you're invisible to their cash flow. If you *are* synced, your app becomes the single source of truth for customer billing and accounting reconciliation becomes automatic. That's stickiness. That's why Aidxn integrates Xero/MYOB/QuickBooks into almost every SaaS we ship for AU customers. The pattern is identical to Pipedrive sync but with three critical differences: AU tax reqs (GST mapping), older APIs (MYOB), and rate limits that will throttle you if you're not careful. This is the production-grade pattern.
Why Accounting Integration Is Your Real Moat
Customers choose SaaS based on features, but they *stay* for integration. A freelancer using your invoicing app will tolerate basic design if invoices automatically land in their Xero account as journal entries — they're not re-keying data, the accountant's reconciliation is instant, and tax time is automatic. Rip out the Xero sync and suddenly the app is a data-entry chore competing with a dozen other invoice generators. Accounting integrations create switching costs. If a customer has 2 years of invoices synced to their Xero, moving to a competitor means rebuilding that history or manually exporting/importing CSVs. They're locked in.
Second, your app becomes the system of record for customer revenue. Instead of customers managing invoices in your app *and* reconciling them in Xero separately, your app is the source and Xero is the synced mirror. This means fewer errors, faster month-end closes, and a feature set your competitors (who don't have accounting integrations) can't match. For AU agencies and contractors, GST reconciliation is a legal requirement — if your app can auto-tag invoices with GST codes and sync them to Xero with the right tax treatment, you've solved a compliance pain point. That's a sales conversation, not a checkbox feature.
Three Platforms Compared: Xero vs MYOB vs QuickBooks
Xero is the AU market leader. It's cloud-native, has a modern OAuth 2.0 API, rate limits are 60 requests/minute per tenant, and the SDK is well-maintained. If you're targeting AU businesses, Xero is the default. Most of Aidxn's integrations are Xero-first.
MYOB is older and more fragmented. The main product is MYOB AccountRight Plus (desktop, not cloud), but MYOB AccountRight Live is their cloud offering. MYOB's API is more restrictive — they use a custom OAuth variant and the documentation is thinner. Rate limits are lower: 20 requests/minute. MYOB is market share on the SME side, especially for practices that have been on it for 10+ years. Don't skip it if your customer base is SME-heavy, but expect slower API responses and more edge cases.
QuickBooks Online dominates the US and is pushing AU. US Intuit users might use QuickBooks AU, but it's still a distant third in Australia. However, if you're building for international customers or US-first, QuickBooks is mandatory. The API is Intuit's Realm API, OAuth 2.0, rate limits are 10 requests/second. It's well-documented but has its own quirks (every request needs a Realm ID, which is the company ID in QuickBooks).
For AU-only: Xero + MYOB covers ~85% of the market. Add QuickBooks if the customer base includes US or APAC clients.
OAuth + Chart-of-Accounts Mapping
The integration has two stages: OAuth to connect the customer's accounting account, and invoice sync to push data into the right ledger accounts.
OAuth flow: Customer clicks "Connect Xero" in your app. You redirect to Xero's authorization endpoint with your client ID, request scopes (invoices, contacts, accounts), and a redirect URI back to your SaaS. Xero shows a login screen, customer authorizes, and you receive an authorization code. Exchange it for an access token (valid for 30 minutes) and a refresh token (valid for 60 days). Store both tokens encrypted in your database linked to the customer's tenant ID. When access token expires, use refresh token to get a new one. This is standard OAuth 2.0 and all three platforms support it identically.
Chart-of-accounts mapping is the hard part. Every business sets up their income and expense accounts differently. A contractor might have "Income - Consulting" at account 200, an agency might have "Income - Design Services" and "Income - Dev Services" split across 200 and 201. Your app can't hardcode accounts — you have to let the customer map their Xero chart of accounts to your billing categories. When an invoice is created for "Design" services, you look up which Xero account the customer mapped to, and post the invoice line item to that account. Without this mapping, your synced invoices land in a catch-all "Other Income" account and the customer's accountant has to reclassify everything manually, defeating the whole purpose.
Implementation: After OAuth, fetch the customer's chart of accounts using the accounting API (GET /accounts). Store the accounts in your database with account code, name, and type. When the customer creates a service or product in your app, let them pick a Xero account from a dropdown. Store the mapping. When an invoice is generated, look up the mapped account and use it in the sync payload.
// OAuth + account mapping flow
// 1. User clicks "Connect Xero"
async function initXeroOAuth(customerId: string) {
const state = crypto.randomBytes(32).toString('hex');
// Store state in session to prevent CSRF
await supabase
.from('oauth_states')
.insert({ customer_id: customerId, state, expires_at: new Date(Date.now() + 10 * 60 * 1000) });
const authUrl = new URL('https://login.xero.com/identity/connect/authorize');
authUrl.searchParams.set('client_id', XERO_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'offline_access accounting');
authUrl.searchParams.set('state', state);
return authUrl.toString();
}
// 2. User authorizes, Xero redirects back with code
async function handleXeroCallback(code: string, state: string, customerId: string) {
// Verify state
const { data: stateRecord } = await supabase
.from('oauth_states')
.select('*')
.eq('state', state)
.eq('customer_id', customerId)
.single();
if (!stateRecord || stateRecord.expires_at < new Date()) {
throw new Error('Invalid or expired state');
}
// Exchange code for tokens
const response = await fetch('https://identity.xero.com/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: XERO_CLIENT_ID,
client_secret: XERO_CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI
})
});
const { access_token, refresh_token, id_token } = await response.json();
// Decode id_token to get tenant IDs
const decoded = jwt.decode(id_token);
const tenantId = decoded.xero_userid; // or extract from token_id claim
// Store tokens encrypted
const { error } = await supabase
.from('xero_connections')
.upsert({
customer_id: customerId,
tenant_id: tenantId,
access_token: encrypt(access_token),
refresh_token: encrypt(refresh_token),
expires_at: new Date(Date.now() + 30 * 60 * 1000)
}, {
onConflict: 'customer_id'
});
if (error) throw error;
// Fetch and store chart of accounts
await syncChartOfAccounts(customerId, tenantId, access_token);
}
// 3. Fetch and store accounts for mapping
async function syncChartOfAccounts(customerId: string, tenantId: string, accessToken: string) {
const xeroApi = new XeroClient({
accessToken,
tenantId
});
const accounts = await xeroApi.getAccounts();
// Store accounts so customer can map them
const accountRecords = accounts.map(acc => ({
customer_id: customerId,
xero_account_code: acc.Code,
xero_account_name: acc.Name,
xero_account_type: acc.Type, // REVENUE, EXPENSE, etc.
xero_account_id: acc.AccountID
}));
await supabase
.from('xero_accounts')
.upsert(accountRecords, {
onConflict: 'customer_id,xero_account_code'
});
}
Invoice Sync: Pushing Data to Xero
When a customer creates an invoice in your app, you immediately sync it to Xero. Don't wait for a background job — the customer expects to see it in Xero within seconds. The payload must include the customer (contact), line items with the mapped GL accounts, tax treatment (GST), and invoice date. Xero's invoice endpoint is straightforward, but you need to handle three cases: create new invoice, update existing, and handle errors (customer doesn't exist in Xero, account unmapped, etc.).
The sync logic: When invoice is saved in your app, trigger a Netlify function (or edge function) that calls Xero's API. First, check if the customer exists in Xero's contacts. If not, create them. Then, look up the mapped GL accounts for each line item. Post the invoice with status "DRAFT" (so the customer can review before submitting). If any part fails, log it and alert the customer — don't silently fail and leave them with invoices stuck in your app but missing from Xero.
GST handling: Every invoice in AU needs tax treatment. Xero has tax types (Tax on Sales, Tax on Purchases, Tax Exempt). When syncing an invoice, include the tax type code. If the customer is GST-registered, use "Tax on Sales" (standard 10%). If they're not, use "Tax Exempt". Store the tax type choice with the invoice mapping so syncs are consistent.
// Invoice sync to Xero
async function syncInvoiceToXero(invoiceId: string, customerId: string) {
const invoice = await supabase
.from('invoices')
.select('*')
.eq('id', invoiceId)
.single();
const xeroConnection = await supabase
.from('xero_connections')
.select('*')
.eq('customer_id', customerId)
.single();
if (!xeroConnection) {
throw new Error('Xero not connected');
}
// Ensure token is fresh
const accessToken = await ensureXeroTokenValid(xeroConnection);
const xeroApi = new XeroClient({
accessToken,
tenantId: xeroConnection.tenant_id
});
// 1. Ensure contact exists
let contact = await xeroApi.getContactByEmail(invoice.customer_email);
if (!contact) {
contact = await xeroApi.createContact({
Name: invoice.customer_name,
EmailAddress: invoice.customer_email,
CompanyNumber: invoice.customer_abn
});
}
// 2. Build line items with mapped accounts
const lineItems = await Promise.all(
invoice.line_items.map(async (item) => {
// Look up the mapped account
const mapping = await supabase
.from('service_account_mappings')
.select('xero_account_id')
.eq('customer_id', customerId)
.eq('service_id', item.service_id)
.single();
if (!mapping) {
throw new Error(
`Service "${item.service_name}" not mapped to a Xero account. ` +
`Update your integration settings.`
);
}
return {
Description: item.service_name,
Quantity: item.quantity,
UnitAmount: item.unit_price,
TaxType: 'Tax on Sales', // or pull from invoice config
AccountCode: mapping.xero_account_id
};
})
);
// 3. Create invoice in Xero
const xeroInvoice = await xeroApi.createInvoice({
Type: 'ACCREC', // AR invoice
Contact: contact,
InvoiceNumber: invoice.invoice_number,
InvoiceDate: invoice.created_at,
DueDate: invoice.due_date,
LineItems: lineItems,
Status: 'DRAFT'
});
// 4. Store sync record
await supabase
.from('invoice_syncs')
.insert({
invoice_id: invoiceId,
xero_invoice_id: xeroInvoice.InvoiceID,
synced_at: new Date().toISOString(),
sync_status: 'success'
});
return xeroInvoice;
}
Handling Rate Limits and Retries
This is where most integrations break in production. Xero's rate limit is 60 requests/minute per tenant. MYOB is 20/minute. If you're syncing 100 invoices in bulk, you'll hit the limit. The solution: queue-based retries with exponential backoff.
When an API call fails with a 429 (Too Many Requests), don't fail the user's experience. Instead, queue the sync to a background job (using Supabase's pg_cron or a simple Netlify scheduled function). Retry after 60 seconds with exponential backoff: 60s, 120s, 240s, etc. Log each retry so you know what succeeded and what's stuck. Set a max retry count (e.g., 5 attempts) and alert the customer if syncing permanently fails after that.
Secondary: batch requests where possible. If you have 50 contacts to sync, don't call the contact endpoint 50 times. Many APIs support batch operations. Xero's batch endpoint lets you POST an array of objects in a single call, counting as 1 request against the rate limit. Read the API docs for batch endpoints and use them.
Third: cache account and contact data locally. Don't fetch the chart of accounts every time — cache it for 24 hours. Don't check if a contact exists by calling the API — store a local cache of contact IDs and emails so you only call the API when you actually need to create or update. This is transparent to the customer but cuts your API usage in half.
Six FAQs
What happens if an invoice is deleted in my app — should I delete it in Xero?
Never delete. If you've synced an invoice to Xero, mark it as CANCELLED in Xero but don't delete. Deletion breaks audit trails and causes reconciliation nightmares. The customer's accountant needs to see the audit trail of all invoices, including cancelled ones, for compliance. Soft-delete locally and mark as CANCELLED in Xero.
What if the customer disconnects their Xero account? Can I re-sync old invoices?
Yes, but carefully. If a customer disconnects Xero, revoke the token and mark the connection as inactive. If they reconnect later, you can re-sync invoices that haven't been synced yet (check your sync log). But don't blindly re-sync everything — you'll create duplicate invoices in Xero. Only sync invoices with sync_status = 'pending'.
How do I handle invoice amendments or credits?
Create separate invoice objects. If a customer amends an invoice (changes qty or price), create a credit note for the original, then create a new invoice for the amended amount. Sync both to Xero. This preserves the audit trail. Some accounting software allows invoice amendments, but MYOB doesn't handle them well — credits are safer.
Can I sync expenses or purchase orders to MYOB/Xero?
Yes. The pattern is identical: create a bill in your app, sync to Xero/MYOB. Bills are ACCPAY (accounts payable) invoices. Chart-of-accounts mapping works the same way. One caveat: expense syncing requires the customer to trust you with supplier data (or you build a supplier directory yourself). Start with invoice sync first, then expand to expenses if customers demand it.
What if Xero changes their API or deprecates an endpoint?
Monitor the Xero developer announcements and set up a calendar reminder for API version deprecations (usually 12 months notice). Store the API version in your sync function so you can migrate gradually. Test against their sandbox before pushing changes to production. Have a comms plan to notify customers of changes that affect them.
How do I test the integration locally?
All three platforms have sandbox/test environments. Xero has a demo tenant (free). MYOB has a test server. QuickBooks has a sandbox realm. Use these for development. Never test against live customer accounts. When you're ready to go live, deploy to your staging environment, connect a test Xero account, create a test invoice, and verify the sync works end-to-end before rolling out to customers.
The Bottom Line
Accounting integrations aren't nice-to-haves for AU SaaS — they're the feature that keeps customers paying. Xero integration is table stakes. MYOB is essential if your customer base includes SMEs. QuickBooks is mandatory if you're targeting US or APAC. The pattern is OAuth + chart-of-accounts mapping + queued syncs with rate-limit handling. Start with contact creation and invoice sync, then expand to expense sync, credit notes, and supplier management as you grow. Customers who sync invoices to their accounting software stay loyal. Those who don't eventually churn because they're doing double data entry. Build the integration first, features second. Ready to ship an AU SaaS with integrated accounting? Check Aidxn Design for accounting-first SaaS architecture or read about CRM + database sync patterns for other integrations.