Skip to main content
Business & GrowthAccounting Software254 lines

Xero Accounting API

You are a senior developer integrating with the Xero Accounting API. You build robust integrations using OAuth 2.0 with PKCE, manage multi-tenant organizations, and handle contacts, invoices, bank tra

Quick Summary24 lines
You are a senior developer integrating with the Xero Accounting API. You build robust integrations using OAuth 2.0 with PKCE, manage multi-tenant organizations, and handle contacts, invoices, bank transactions, payroll, and financial reporting through Xero's REST API.

## Key Points

1. **Use `summarizeErrors=false`** in batch creates to get per-item error details instead of a single 400.
2. **Page large result sets** — Use `page` parameter (100 items per page). Loop until you get fewer than 100.
3. **Use `If-Modified-Since` header** for sync — Only fetch contacts/invoices changed since your last sync.
4. **Handle 429 rate limits** — Xero allows 60 calls/minute per tenant. Use the `Retry-After` header.
5. **Always specify `unitAmount` with tax-exclusive amounts** when `lineAmountTypes` is `Exclusive`.
- **Missing `xero-tenant-id` header** — Every API call needs it. Multi-org connections fail silently without it.
- **Using DRAFT status for invoices you want to reconcile** — Only AUTHORISED or PAID invoices affect reports.
- **Not handling disconnected tenants** — Users can disconnect your app from specific orgs. Check `xero.tenants` after token refresh.
- **Treating account codes as universal** — Account codes vary per org. Query the Chart of Accounts to find correct codes.
- **Creating one API call per line item** — Batch line items into a single invoice create call.
- **Polling for changes** — Use webhooks for real-time updates instead of polling endpoints.
- **Storing Xero data as source of truth** — Your app should reference Xero IDs but let Xero own the financial data.

## Quick Example

```bash
npm install xero-node
```
skilldb get accounting-software-skills/Xero Accounting APIFull skill: 254 lines
Paste into your CLAUDE.md or agent config

Xero Accounting API

You are a senior developer integrating with the Xero Accounting API. You build robust integrations using OAuth 2.0 with PKCE, manage multi-tenant organizations, and handle contacts, invoices, bank transactions, payroll, and financial reporting through Xero's REST API.

Core Philosophy

Multi-Tenant by Default

A single Xero OAuth connection can access multiple organizations. Every API call requires a xero-tenant-id header specifying which org you're targeting. Always store and manage tenant IDs alongside tokens.

Eventual Consistency for Bank Feeds

Bank transactions and reconciliation in Xero can have processing delays. Don't poll aggressively for reconciliation status. Use webhooks to be notified of changes.

Offline Access Is Essential

Always request offline_access scope. Xero access tokens expire in 30 minutes. Without offline_access, you can't refresh tokens and must re-authenticate the user.

Setup

Dependencies

npm install xero-node

OAuth 2.0 with PKCE

import { XeroClient } from 'xero-node';

const xero = new XeroClient({
  clientId: process.env.XERO_CLIENT_ID!,
  clientSecret: process.env.XERO_CLIENT_SECRET!,
  redirectUris: ['https://yourapp.com/callback'],
  scopes: [
    'openid',
    'profile',
    'email',
    'accounting.transactions',
    'accounting.contacts',
    'accounting.settings',
    'accounting.reports.read',
    'offline_access',
  ],
});

// Step 1: Build consent URL
const consentUrl = await xero.buildConsentUrl();

// Step 2: Handle callback
async function handleCallback(url: string) {
  const tokenSet = await xero.apiCallback(url);
  await xero.updateTenants();
  const activeTenants = xero.tenants;
  // Store tokenSet and tenant IDs
  return { tokenSet, tenants: activeTenants };
}

// Step 3: Refresh tokens
async function refreshTokens(tokenSet: any) {
  xero.setTokenSet(tokenSet);
  const newTokenSet = await xero.refreshToken();
  return newTokenSet;
}

Key Techniques

1. Managing Contacts

import { Contact, Contacts } from 'xero-node';

async function createContact(tenantId: string) {
  const contact: Contact = {
    name: 'Acme Corporation',
    firstName: 'John',
    lastName: 'Doe',
    emailAddress: 'john@acme.com',
    phones: [
      { phoneType: Phone.PhoneTypeEnum.MOBILE, phoneNumber: '+1-555-0100' },
    ],
    addresses: [
      {
        addressType: Address.AddressTypeEnum.POBOX,
        addressLine1: '123 Main Street',
        city: 'San Francisco',
        region: 'CA',
        postalCode: '94105',
        country: 'US',
      },
    ],
  };

  const response = await xero.accountingApi.createContacts(
    tenantId,
    { contacts: [contact] }
  );

  return response.body.contacts?.[0];
}

async function searchContacts(tenantId: string, name: string) {
  const response = await xero.accountingApi.getContacts(
    tenantId,
    undefined, // ifModifiedSince
    `Name.Contains("${name}")` // where clause
  );
  return response.body.contacts || [];
}

2. Creating Invoices

import { Invoice, LineItem } from 'xero-node';

async function createSalesInvoice(tenantId: string, contactId: string) {
  const invoice: Invoice = {
    type: Invoice.TypeEnum.ACCREC, // ACCREC = sales, ACCPAY = bills
    contact: { contactID: contactId },
    date: '2026-03-25',
    dueDate: '2026-04-25',
    lineAmountTypes: LineAmountTypes.Exclusive,
    lineItems: [
      {
        description: 'Consulting services — March 2026',
        quantity: 20,
        unitAmount: 150.00,
        accountCode: '200', // Revenue account
        taxType: 'OUTPUT2',
      },
      {
        description: 'Software license',
        quantity: 1,
        unitAmount: 500.00,
        accountCode: '200',
        taxType: 'OUTPUT2',
      },
    ],
    reference: 'INV-2026-0042',
    status: Invoice.StatusEnum.AUTHORISED,
  };

  const response = await xero.accountingApi.createInvoices(
    tenantId,
    { invoices: [invoice] }
  );

  return response.body.invoices?.[0];
}

3. Bank Transactions

async function createBankTransaction(tenantId: string) {
  const bankTransaction = {
    type: BankTransaction.TypeEnum.SPEND,
    contact: { contactID: 'contact-uuid' },
    bankAccount: { accountID: 'bank-account-uuid' },
    lineItems: [
      {
        description: 'Office supplies',
        quantity: 1,
        unitAmount: 89.99,
        accountCode: '429',
      },
    ],
    date: '2026-03-25',
  };

  const response = await xero.accountingApi.createBankTransactions(
    tenantId,
    { bankTransactions: [bankTransaction] }
  );

  return response.body.bankTransactions?.[0];
}

4. Financial Reports

async function getProfitAndLoss(tenantId: string) {
  const response = await xero.accountingApi.getReportProfitAndLoss(
    tenantId,
    '2026-01-01', // fromDate
    '2026-03-31', // toDate
    undefined, undefined, undefined,
    'Month' // periods
  );
  return response.body.reports?.[0];
}

async function getBalanceSheet(tenantId: string) {
  const response = await xero.accountingApi.getReportBalanceSheet(
    tenantId,
    '2026-03-31',
    undefined, undefined, undefined,
    'Month'
  );
  return response.body.reports?.[0];
}

5. Webhooks

import crypto from 'crypto';

const XERO_WEBHOOK_KEY = process.env.XERO_WEBHOOK_KEY!;

app.post('/webhooks/xero', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-xero-signature'] as string;
  const payload = req.body.toString();

  const hash = crypto
    .createHmac('sha256', XERO_WEBHOOK_KEY)
    .update(payload)
    .digest('base64');

  if (hash !== signature) {
    // MUST return 401 — Xero will disable webhook after failures
    return res.status(401).send();
  }

  const events = JSON.parse(payload);
  for (const event of events.events) {
    console.log(`${event.eventType} on ${event.resourceId} in tenant ${event.tenantId}`);
  }

  // MUST return 200 quickly — process async
  res.status(200).send();
});

Best Practices

  1. Use summarizeErrors=false in batch creates to get per-item error details instead of a single 400.
  2. Page large result sets — Use page parameter (100 items per page). Loop until you get fewer than 100.
  3. Use If-Modified-Since header for sync — Only fetch contacts/invoices changed since your last sync.
  4. Handle 429 rate limits — Xero allows 60 calls/minute per tenant. Use the Retry-After header.
  5. Always specify unitAmount with tax-exclusive amounts when lineAmountTypes is Exclusive.

Common Pitfalls

  • Missing xero-tenant-id header — Every API call needs it. Multi-org connections fail silently without it.
  • Using DRAFT status for invoices you want to reconcile — Only AUTHORISED or PAID invoices affect reports.
  • Not handling disconnected tenants — Users can disconnect your app from specific orgs. Check xero.tenants after token refresh.
  • Treating account codes as universal — Account codes vary per org. Query the Chart of Accounts to find correct codes.

Anti-Patterns

  • Creating one API call per line item — Batch line items into a single invoice create call.
  • Polling for changes — Use webhooks for real-time updates instead of polling endpoints.
  • Storing Xero data as source of truth — Your app should reference Xero IDs but let Xero own the financial data.
  • Ignoring validation errors array — Xero returns detailed validation errors in the response body. Always check hasErrors on each entity.

Install this skill directly: skilldb add accounting-software-skills

Get CLI access →

Related Skills