biz/accounting/xero

XERO

Xero cloud accounting AI integration expert.

production Any HTTP client, Cloudflare Workers, Node.js
improves: biz/accounting

Xero Accounting AI

Xero is the dominant cloud accounting platform for SMEs in South Africa, Australia, New Zealand, and the UK. It manages the general ledger, accounts payable/receivable, bank feeds, and financial reporting — all accessible via a well-documented REST API. The AI pairing enables natural-language invoice management, automated reconciliation suggestions, and instant financial narrative from Xero data.

API base: https://api.xero.com/api.xro/2.0

Authentication

Xero uses OAuth 2.0 with PKCE for public apps and client credentials for private/machine-to-machine flows. Access tokens expire after 30 minutes; refresh tokens expire after 60 days of inactivity.

Credentials: Store as environment variables — never hardcode.

XERO_CLIENT_ID=...
XERO_CLIENT_SECRET=...
XERO_REDIRECT_URI=...
// OAuth 2.0 — Authorization Code + PKCE flow
async function getXeroToken(code, codeVerifier) {
  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: process.env.XERO_CLIENT_ID,
      code,
      redirect_uri: process.env.XERO_REDIRECT_URI,
      code_verifier: codeVerifier,
    })
  });
  return response.json();
  // Returns: { access_token, refresh_token, expires_in: 1800, token_type: 'Bearer' }
}

// Refresh token (access tokens expire after 30 min)
async function refreshXeroToken(refreshToken) {
  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: 'refresh_token',
      client_id: process.env.XERO_CLIENT_ID,
      refresh_token: refreshToken,
    })
  });
  return response.json();
}

OAuth Scopes

ScopeAccess
openid profile emailUser identity
accounting.transactionsInvoices, credit notes, payments, bank transactions
accounting.contactsContacts (customers, suppliers)
accounting.reports.readFinancial reports (P&L, balance sheet, trial balance)
accounting.settingsChart of accounts, tax rates, currencies
accounting.attachmentsFile attachments on transactions

Multi-Tenancy

After token exchange, call GET /connections to retrieve the list of authorised organisations. Store the tenantId UUID — it is required as the Xero-Tenant-Id header on every subsequent API call.

const connections = await fetch('https://api.xero.com/connections', {
  headers: { 'Authorization': `Bearer ${token}` }
}).then(r => r.json());
// [{ tenantId, tenantName, tenantType, ... }]

Core Objects

ObjectEndpointKey FieldsNotes
Contact/ContactsContactID, Name, EmailAddress, AccountNumber, IsCustomer, IsSupplierUsed for both customers and suppliers
Account/AccountsAccountID, Code, Name, Type, TaxTypeChart of accounts
Invoice/InvoicesInvoiceID, Type (ACCREC/ACCPAY), ContactID, LineItems, DueDate, StatusACCREC = AR, ACCPAY = AP
Payment/PaymentsPaymentID, InvoiceID, AccountID, Amount, DateLinks invoice to bank account
BankTransaction/BankTransactionsBankTransactionID, Type, Contact, LineItems, BankAccountManual bank entries
BankStatement/BankStatementsStatementID, LinesBank feed import
Report/Reports/{ReportID}ProfitAndLoss, BalanceSheet, TrialBalance, AgedReceivablesByContactRead-only financial reports
CreditNote/CreditNotesCreditNoteID, Type, Status, LineItemsAR/AP credit notes
const XERO_BASE = 'https://api.xero.com/api.xro/2.0';

async function xeroGet(path, tenantId, token) {
  const res = await fetch(`${XERO_BASE}${path}`, {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Xero-Tenant-Id': tenantId,
      'Accept': 'application/json',
    }
  });
  if (!res.ok) throw new Error(`Xero API ${res.status}: ${await res.text()}`);
  return res.json();
}

// Fetch contacts (customers)
const { Contacts } = await xeroGet('/Contacts?where=IsCustomer=true', tenantId, token);

Invoicing & Payments

Xero invoices are typed: ACCREC for accounts receivable (money owed to you) and ACCPAY for accounts payable (money you owe). Invoices move through a defined status lifecycle.

async function createInvoice(tenantId, token, { contactId, lineItems, dueDate, reference }) {
  const body = {
    Invoices: [{
      Type: 'ACCREC',
      Contact: { ContactID: contactId },
      DueDate: dueDate,                    // "2026-04-30"
      Reference: reference,
      Status: 'DRAFT',                     // DRAFT → SUBMITTED → AUTHORISED → PAID
      LineItems: lineItems.map(li => ({
        Description: li.description,
        Quantity: li.quantity,
        UnitAmount: li.unitAmount,
        AccountCode: li.accountCode,       // e.g. "200" (Sales)
        TaxType: 'OUTPUT2',                // SA VAT at 15%
      }))
    }]
  };
  const res = await fetch(`${XERO_BASE}/Invoices`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Xero-Tenant-Id': tenantId,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body)
  });
  return res.json();
}

Invoice Lifecycle

StatusMeaningTransitions
DRAFTCreated, not submitted→ SUBMITTED or AUTHORISED
SUBMITTEDAwaiting approval→ AUTHORISED or DELETED
AUTHORISEDApproved, sent to customer→ PAID or VOIDED
PAIDFully paidTerminal
VOIDEDCancelledTerminal
DELETEDSoft deletedTerminal

Applying a Payment

async function applyPayment(tenantId, token, { invoiceId, accountId, amount, date }) {
  const body = {
    Payments: [{
      Invoice: { InvoiceID: invoiceId },
      Account: { AccountID: accountId },   // Bank account the payment was received into
      Amount: amount,
      Date: date,                           // "2026-04-15"
      Reference: 'EFT payment received',
    }]
  };
  const res = await fetch(`${XERO_BASE}/Payments`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Xero-Tenant-Id': tenantId,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body)
  });
  return res.json();
}

AR Aging Query

// Get all authorised AR invoices overdue (DueDate < today)
const today = new Date().toISOString().split('T')[0];
const { Invoices } = await xeroGet(
  `/Invoices?where=Type=="ACCREC"&&Status=="AUTHORISED"&&DueDate<DateTime(${today.replace(/-/g,',,')})&order=DueDate ASC`,
  tenantId, token
);

const aging = Invoices.map(inv => ({
  contact: inv.Contact.Name,
  invoiceNumber: inv.InvoiceNumber,
  amountDue: inv.AmountDue,
  dueDate: inv.DueDate,
  daysOverdue: Math.floor((Date.now() - new Date(inv.DueDate)) / 86400000)
}));

Bank Reconciliation

Xero bank feeds import statement lines that must be matched to existing transactions or manually coded to the correct GL account. Unreconciled lines accumulate until matched.

// Get unreconciled bank statement lines
async function getUnreconciledLines(tenantId, token, bankAccountId) {
  const { BankStatements } = await xeroGet(
    `/BankStatements?BankAccountID=${bankAccountId}&Unreconciled=true`,
    tenantId, token
  );
  return BankStatements.flatMap(s => s.Lines);
}

// AI reconciliation suggestion engine
async function suggestMatches(statementLine, openInvoices) {
  const amountMatches = openInvoices.filter(inv =>
    Math.abs(inv.AmountDue - statementLine.Amount) < 0.01
  );
  const nameMatches = openInvoices.filter(inv =>
    statementLine.Payee?.toLowerCase().includes(
      inv.Contact.Name.toLowerCase().split(' ')[0]
    )
  );
  return [...new Set([...amountMatches, ...nameMatches])].map(inv => ({
    invoice: inv,
    confidence: amountMatches.includes(inv) && nameMatches.includes(inv) ? 'HIGH'
               : amountMatches.includes(inv) ? 'MEDIUM' : 'LOW'
  }));
}

High-confidence matches (amount + payee both match) can be auto-reconciled. Always queue low-confidence matches for human review.

Reporting

Xero's Reports API returns structured JSON for all standard financial statements. The AI converts this into plain-English narratives, summaries, and variance analyses.

// P&L for a date range
async function getProfitAndLoss(tenantId, token, fromDate, toDate) {
  const { Reports } = await xeroGet(
    `/Reports/ProfitAndLoss?fromDate=${fromDate}&toDate=${toDate}&standardLayout=true`,
    tenantId, token
  );
  return Reports[0];
}

// Balance Sheet as at a date
async function getBalanceSheet(tenantId, token, date) {
  const { Reports } = await xeroGet(
    `/Reports/BalanceSheet?date=${date}&standardLayout=true`,
    tenantId, token
  );
  return Reports[0];
}

// Aged Receivables — who owes what
async function getAgedReceivables(tenantId, token) {
  const { Reports } = await xeroGet(
    `/Reports/AgedReceivablesByContact`,
    tenantId, token
  );
  return Reports[0];
}

MCP Tools

ToolDescription
get_contactsList customers and suppliers with filtering
create_invoiceDraft or authorise an ACCREC/ACCPAY invoice
get_invoicesQuery invoices by status, contact, date range
apply_paymentMark invoice as paid against a bank account
get_bank_transactionsFetch unreconciled bank statement lines
suggest_reconciliationAI-powered match suggestions for statement lines
get_profit_and_lossP&L report for any date range
get_balance_sheetBalance sheet as at any date
get_aged_receivablesAged debtor analysis by contact
get_aged_payablesAged creditor analysis by contact
create_credit_noteIssue AR/AP credit notes
get_chart_of_accountsFull account list with codes and types

Key Facts

Common Gotchas

See Also