Skip to main content
RapidDev - Software Development Agency
bolt-ai-integrationsBolt Chat + API Route

How to Integrate Bolt.new with Xero

Integrate Bolt.new with Xero by creating a Xero app at developer.xero.com, implementing OAuth 2.0 with PKCE through Next.js API routes (requires deployment for the callback URL), then calling Xero's REST API to read invoices, contacts, and financial reports. The xero-node SDK handles token management. Store credentials in .env and never expose them client-side. Xero rate limit is 60 requests per minute.

What you'll learn

  • How to create a Xero developer app and configure OAuth 2.0 credentials at developer.xero.com
  • How Xero's OAuth 2.0 PKCE flow differs from standard OAuth and how to implement it in Next.js
  • How to use the xero-node SDK to handle token management and API calls
  • How to fetch invoices, contacts, and financial reports from Xero via Next.js API routes
  • How to handle Xero's tenant ID system when users connect multiple Xero organizations
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate18 min read45 minutesOtherApril 2026RapidDev Engineering Team
TL;DR

Integrate Bolt.new with Xero by creating a Xero app at developer.xero.com, implementing OAuth 2.0 with PKCE through Next.js API routes (requires deployment for the callback URL), then calling Xero's REST API to read invoices, contacts, and financial reports. The xero-node SDK handles token management. Store credentials in .env and never expose them client-side. Xero rate limit is 60 requests per minute.

Integrating Xero Accounting Data into Bolt.new Apps

Xero is the accounting platform of choice for small and medium businesses in the United Kingdom, Australia, New Zealand, and much of Southeast Asia. While QuickBooks dominates North American markets, Xero has a 40%+ market share in these regions and a developer ecosystem to match. If your Bolt app targets businesses in these geographies — or globally, since Xero has a significant presence worldwide — a Xero integration puts accounting data directly in your application without requiring users to context-switch to Xero's interface.

Xero's REST API gives you access to the full accounting data model: contacts (customers and suppliers), invoices (both sales and purchase), bank transactions, bank reconciliations, accounts (chart of accounts), reporting (profit & loss, balance sheet, cash flow), and payroll in supported regions. The API is mature and well-documented, with a long history of developer support going back to 2009. Xero uses OAuth 2.0 with PKCE (Proof Key for Code Exchange) for authentication — this is a security-enhanced variant of the standard OAuth flow that does not require a client secret for the authorization step, though Xero still issues a client secret that your server uses for token operations.

Practically, setting up a Xero integration in Bolt.new follows the same pattern as other OAuth-based accounting tools: register your app, implement the auth flow through server-side Next.js routes, and access the API after users authorize. The main Xero-specific detail is the tenant ID system — when a user authorizes your app, they may have access to multiple Xero organizations. Your app receives a list of tenant IDs and must let the user select which organization to connect, or handle data for all connected organizations separately.

Integration method

Bolt Chat + API Route

Bolt generates the Xero integration code — OAuth 2.0 PKCE authorization routes, API handlers for invoices and contacts, and React dashboard components — through conversation with the AI. Xero uses OAuth 2.0 with PKCE, meaning users authorize your app through Xero's consent screen. The OAuth callback requires a publicly accessible HTTPS URL, so you must deploy to Netlify or Bolt Cloud before testing the auth flow. All Xero API calls go through server-side Next.js routes to keep credentials out of the browser.

Prerequisites

  • A Xero account (free trial at xero.com) and access to a Xero organization (sandbox available via the Xero Demo Company)
  • A Xero developer app registered at developer.xero.com with OAuth 2.0 credentials (Client ID and Client Secret)
  • A deployed Bolt.new app on Netlify or Bolt Cloud (the OAuth callback URL must be a publicly accessible HTTPS address)
  • The xero-node npm package installed in your Bolt project (prompt: 'Install xero-node npm package')
  • Your Xero organization's Tenant ID (obtained after the first OAuth authorization)

Step-by-step guide

1

Create a Xero developer app and configure OAuth 2.0

Before writing any code, register your application in Xero's developer portal. Go to developer.xero.com and sign in with your Xero account. Navigate to My Apps and click New App. Fill in the app details: app name (visible to users during authorization), company or application URL (your website or the deployed app URL), and select OAuth 2.0 as the OAuth version. Choose 'Web App' as the integration type. In the Redirect URIs field, add both your deployed URL (e.g., https://your-app.netlify.app/api/xero/callback) and http://localhost:3000/api/xero/callback for local development. For scopes, select the access your integration needs. Common scopes: openid and profile (always required), email (for user identification), accounting.transactions (invoices, bills, credit notes), accounting.contacts (customers, suppliers), accounting.reports.read (financial reports), and offline_access (for refresh tokens that allow long-lived access). Only select the scopes you actually use — Xero's consent screen shows users exactly what your app is requesting. After saving, your app shows the Client ID and Client Secret. Copy both — you will need them for your .env file. The Client ID is safe to include in the authorization URL (it identifies your app). The Client Secret must never appear in client-side code — it is only used server-side for token operations. To access Xero's demo company for testing without affecting real data, create a free Xero account and go to the Xero Demo Company. It is pre-populated with sample invoices, contacts, and bank transactions. Your API token works against this demo company the same as a real organization.

Bolt.new Prompt

Set up Xero OAuth 2.0 in my Next.js app using the xero-node SDK. Create lib/xero-client.ts that exports a getXeroClient function returning a configured XeroClient with XERO_CLIENT_ID, XERO_CLIENT_SECRET, and XERO_REDIRECT_URI from environment variables. Create /api/xero/authorize that generates the Xero auth URL with scopes: openid, profile, email, accounting.transactions, accounting.contacts, accounting.reports.read, offline_access. Redirect to the auth URL.

Paste this in Bolt.new chat

lib/xero-client.ts
1// .env.local
2XERO_CLIENT_ID=your_xero_client_id
3XERO_CLIENT_SECRET=your_xero_client_secret
4XERO_REDIRECT_URI=https://your-app.netlify.app/api/xero/callback
5
6// lib/xero-client.ts
7import { XeroClient } from 'xero-node';
8
9export function getXeroClient(): XeroClient {
10 return new XeroClient({
11 clientId: process.env.XERO_CLIENT_ID!,
12 clientSecret: process.env.XERO_CLIENT_SECRET!,
13 redirectUris: [process.env.XERO_REDIRECT_URI!],
14 scopes: [
15 'openid',
16 'profile',
17 'email',
18 'accounting.transactions',
19 'accounting.contacts',
20 'accounting.reports.read',
21 'offline_access',
22 ],
23 httpTimeout: 30000,
24 });
25}
26
27// app/api/xero/authorize/route.ts
28import { NextResponse } from 'next/server';
29import { getXeroClient } from '@/lib/xero-client';
30
31export async function GET() {
32 const xero = getXeroClient();
33 const consentUrl = await xero.buildConsentUrl();
34 return NextResponse.redirect(consentUrl);
35}

Pro tip: The xero-node SDK manages PKCE (code challenge/verifier) automatically when you use buildConsentUrl() and apiCallback(). You do not need to manually generate or validate the PKCE parameters — the SDK handles this security detail internally.

Expected result: Visiting /api/xero/authorize redirects to Xero's authorization page showing your app name and the requested scopes. The Xero consent screen lists each permission you requested, which users review before clicking Allow Access.

2

Handle the OAuth callback and store tokens and tenant ID

After the user authorizes your app on Xero's consent screen, Xero redirects to your callback URL with an authorization code and state parameter. The xero-node SDK's apiCallback method handles the code exchange — it takes the full callback URL, validates the state, exchanges the code for tokens, and fetches the list of Xero tenants (organizations) the user has authorized your app to access. The tenant system is Xero-specific and important to understand. A single Xero user may have access to multiple organizations — for example, an accountant who manages several clients' Xero accounts. After authorization, you receive a tenants array where each item has a tenantId (UUID), tenantName, and tenantType. For most single-company use cases, you just use tenants[0].tenantId. For multi-tenant applications serving accountants or agencies, store all tenant IDs and let the user select which organization to work with. Store the tokens and selected tenant ID so your API routes can use them for subsequent calls. The xero-node client maintains tokens internally, but since Next.js API routes are stateless (a new instance per request), you need to serialize and deserialize the token set on each request. Store the token set as JSON in an HTTP-only cookie or in a database keyed by user ID. Critical note: the OAuth callback is an incoming redirect from Xero to your server. Bolt's WebContainer cannot receive these incoming connections — it runs entirely in the browser tab. Deploy your app first, register the deployed URL as the redirect URI in developer.xero.com, and test the complete auth flow on the deployed site.

Bolt.new Prompt

Create the Xero OAuth callback at /api/xero/callback. Use the xero-node apiCallback method with the full request URL. After successful auth, get the tenants list and store the first tenant's ID. Serialize the xero client's token set to JSON and store it in an HTTP-only cookie called xero_token_set. Store the tenant ID in a separate cookie called xero_tenant_id. Redirect to /dashboard on success. Create a helper lib/xero-auth.ts that exports getAuthenticatedXeroClient which reads the cookies and sets credentials on a fresh XeroClient.

Paste this in Bolt.new chat

app/api/xero/callback/route.ts
1// app/api/xero/callback/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { getXeroClient } from '@/lib/xero-client';
4
5export async function GET(request: NextRequest) {
6 const xero = getXeroClient();
7
8 try {
9 const tokenSet = await xero.apiCallback(request.url);
10 await xero.updateTenants();
11 const tenants = xero.tenants;
12
13 if (!tenants || tenants.length === 0) {
14 return NextResponse.redirect(new URL('/error?message=no_tenants', request.url));
15 }
16
17 const primaryTenantId = tenants[0].tenantId;
18 const tokenSetJson = JSON.stringify(tokenSet);
19
20 const response = NextResponse.redirect(new URL('/dashboard', request.url));
21 const cookieOptions = {
22 httpOnly: true,
23 secure: process.env.NODE_ENV === 'production',
24 sameSite: 'lax' as const,
25 maxAge: 60 * 60 * 24 * 30,
26 path: '/',
27 };
28
29 response.cookies.set('xero_token_set', tokenSetJson, cookieOptions);
30 response.cookies.set('xero_tenant_id', primaryTenantId, cookieOptions);
31 response.cookies.set(
32 'xero_tenants',
33 JSON.stringify(tenants.map((t) => ({ id: t.tenantId, name: t.tenantName }))),
34 cookieOptions
35 );
36
37 return response;
38 } catch (error) {
39 console.error('Xero callback error:', error);
40 return NextResponse.redirect(new URL('/error?message=xero_auth_failed', request.url));
41 }
42}
43
44// lib/xero-auth.ts
45import { cookies } from 'next/headers';
46import { TokenSet } from 'xero-node';
47import { getXeroClient } from './xero-client';
48
49export async function getAuthenticatedXeroClient() {
50 const cookieStore = cookies();
51 const tokenCookie = cookieStore.get('xero_token_set');
52 const tenantCookie = cookieStore.get('xero_tenant_id');
53
54 if (!tokenCookie || !tenantCookie) {
55 throw new Error('Not authenticated with Xero');
56 }
57
58 const tokenSet = JSON.parse(tokenCookie.value) as TokenSet;
59 const tenantId = tenantCookie.value;
60
61 const xero = getXeroClient();
62 await xero.setTokenSet(tokenSet);
63 await xero.updateTenants();
64
65 return { xero, tenantId };
66}

Pro tip: Xero access tokens expire after 30 minutes. The xero-node SDK automatically refreshes tokens using the refresh token if offline_access was requested in the scopes. After API calls, check if the token set changed (compare expiry times) and update your stored cookie or database record with the fresh token set.

Expected result: After authorizing on Xero's consent screen, you are redirected to /dashboard and xero_token_set and xero_tenant_id cookies are set. Subsequent API routes can use getAuthenticatedXeroClient to make authenticated calls.

3

Fetch invoices and contacts from the Xero API

With authentication working, create Next.js API routes that retrieve Xero accounting data. The xero-node SDK provides typed methods for every Xero API endpoint — you call methods like xero.accountingApi.getInvoices() and xero.accountingApi.getContacts() with the tenant ID and optional filter parameters. For invoices, Xero supports filtering by status (DRAFT, SUBMITTED, DELETED, AUTHORISED, VOIDED, PAID), contact ID, date range, and modified date. The most useful filter for an accounts receivable dashboard is Status: AUTHORISED — these are invoices that have been approved and issued but not yet paid. AUTHORISED invoices have a remaining AmountDue greater than zero. Paid invoices show AmountDue as 0. Xero invoice objects include: InvoiceID, Type (ACCREC for sales invoices, ACCPAY for purchase invoices), InvoiceNumber, Reference, Contact (name and ID), Date, DueDate, Status, LineItems (description, quantity, unit amount, account code, tax type), SubTotal, TotalTax, Total, and AmountDue. For aging calculations, compare DueDate to today's date to determine which aging bucket each invoice belongs in. For contacts, xero.accountingApi.getContacts() retrieves customers and suppliers. Contacts include Name, EmailAddress, Phones, Addresses, and whether they are customers, suppliers, or both. Use the IsCustomer and IsSupplier boolean fields to filter by contact type. Xero's rate limit is 60 requests per minute. For dashboards that make multiple API calls on page load (e.g., invoices + contacts + reports), use Promise.all to run them concurrently rather than sequentially, staying within the rate limit while loading faster.

Bolt.new Prompt

Create two Xero API routes. First: /api/xero/invoices that fetches AUTHORISED invoices using xero.accountingApi.getInvoices, filtered by type ACCREC (accounts receivable). Return normalized invoices with id, invoiceNumber, status, date, dueDate, total, amountDue, amountPaid, currency, and contact (name, email). Calculate daysOverdue based on today's date. Second: /api/xero/contacts that fetches active customer contacts and returns id, name, email, phone, and isCustomer/isSupplier flags.

Paste this in Bolt.new chat

app/api/xero/invoices/route.ts
1// app/api/xero/invoices/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { Invoice } from 'xero-node';
4import { getAuthenticatedXeroClient } from '@/lib/xero-auth';
5
6export async function GET(request: NextRequest) {
7 try {
8 const { xero, tenantId } = await getAuthenticatedXeroClient();
9
10 const { searchParams } = new URL(request.url);
11 const statusFilter = searchParams.get('status') ?? 'AUTHORISED';
12
13 const response = await xero.accountingApi.getInvoices(
14 tenantId,
15 undefined, // ifModifiedSince
16 undefined, // where — use status param instead
17 undefined, // order
18 undefined, // IDs
19 undefined, // invoiceNumbers
20 undefined, // contactIDs
21 [statusFilter as Invoice.StatusEnum],
22 undefined, // page
23 false, // includeArchived
24 false, // createdByMyApp
25 undefined, // unitdp
26 false // summaryOnly
27 );
28
29 const today = new Date();
30 const invoices = (response.body.invoices ?? []).map((inv) => {
31 const dueDate = inv.dueDate ? new Date(inv.dueDate) : null;
32 const daysOverdue = dueDate && dueDate < today && inv.status !== 'PAID'
33 ? Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
34 : 0;
35
36 return {
37 id: inv.invoiceID,
38 invoiceNumber: inv.invoiceNumber,
39 status: inv.status,
40 date: inv.date,
41 dueDate: inv.dueDate,
42 total: inv.total ?? 0,
43 amountDue: inv.amountDue ?? 0,
44 amountPaid: inv.amountPaid ?? 0,
45 currency: inv.currencyCode ?? 'USD',
46 contactName: inv.contact?.name ?? 'Unknown',
47 contactEmail: inv.contact?.emailAddress ?? '',
48 daysOverdue,
49 agingBucket:
50 daysOverdue === 0 ? 'current'
51 : daysOverdue <= 30 ? '1-30'
52 : daysOverdue <= 60 ? '31-60'
53 : daysOverdue <= 90 ? '61-90'
54 : '90+',
55 };
56 });
57
58 const totalOutstanding = invoices.reduce((sum, inv) => sum + inv.amountDue, 0);
59 const totalOverdue = invoices.filter((i) => i.daysOverdue > 0).reduce((sum, i) => sum + i.amountDue, 0);
60
61 return NextResponse.json({ invoices, totalOutstanding, totalOverdue, count: invoices.length });
62 } catch (error) {
63 const message = error instanceof Error ? error.message : 'Failed to fetch invoices';
64 return NextResponse.json({ error: message }, { status: 500 });
65 }
66}

Pro tip: Xero dates come back as JavaScript Date objects when using xero-node, but when serialized to JSON they become ISO strings. The aging calculation above uses new Date(inv.dueDate) which handles both. If you encounter NaN in date calculations, check whether the date field is undefined (not all invoices have due dates).

Expected result: Calling /api/xero/invoices returns authorized invoices with aging bucket classifications. Invoices overdue by more than 90 days appear in the '90+' bucket. Total outstanding and overdue amounts are included in the response.

4

Fetch Xero reports and build a financial dashboard

Xero's Reports API provides access to standard financial reports: Profit & Loss (ProfitAndLoss), Balance Sheet (BalanceSheet), Cash Flow (CashSummary), Aged Receivables (AgedReceivablesDetail), Aged Payables (AgedPayablesDetail), and bank reconciliation summaries. These are the same reports financial teams use in the Xero interface, accessible via your API. The Reports API returns a structured JSON object with the report rows, including header rows, summary rows, and section rows. Each row has a RowType and Cells array. The structure mirrors how the report appears in Xero's interface — section headers, line items, and totals. Parse this structure by filtering for rows where RowType is 'Row' (data rows) versus 'SectionHeader' or 'SummaryRow'. For a P&L widget, call xero.accountingApi.getReportProfitAndLoss with the tenantId, fromDate, toDate, and optional periods (for multi-period comparison). The response includes rows for revenue categories, cost of goods sold, gross profit, operating expenses, and net profit. Extract the summary values for a clean metric card display. Combining the invoices and reports data, you can build a comprehensive financial dashboard that shows real-time accounting data from Xero alongside your app's own operational metrics. This is a high-value integration for SaaS companies that want to surface financial KPIs alongside product usage data, or for agencies managing multiple client accounts who want a unified view without logging into each client's Xero account separately.

Bolt.new Prompt

Create a /api/xero/reports/pl route that fetches the Xero Profit and Loss report for a date range (fromDate, toDate as query params). Use xero.accountingApi.getReportProfitAndLoss. Parse the report rows to extract: totalRevenue (sum of revenue section), totalCostOfSales, grossProfit, totalOperatingExpenses, and netProfit. Return these as a clean summary object. Also return the raw rows for detailed display. Handle the Xero report row structure (RowType: Header, SectionHeader, Row, SummaryRow).

Paste this in Bolt.new chat

app/api/xero/reports/pl/route.ts
1// app/api/xero/reports/pl/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { getAuthenticatedXeroClient } from '@/lib/xero-auth';
4
5interface ReportCell {
6 Value?: string;
7 Attributes?: Array<{ Value: string; Id: string }>;
8}
9
10interface ReportRow {
11 RowType: string;
12 Title?: string;
13 Cells?: ReportCell[];
14 Rows?: ReportRow[];
15}
16
17function extractAmount(cell: ReportCell | undefined): number {
18 const val = cell?.Value?.replace(/[^0-9.-]/g, '');
19 return val ? parseFloat(val) : 0;
20}
21
22function findSectionTotal(rows: ReportRow[], sectionTitle: string): number {
23 for (const row of rows) {
24 if (row.RowType === 'Section' && row.Title === sectionTitle) {
25 const summaryRow = row.Rows?.find((r) => r.RowType === 'SummaryRow');
26 if (summaryRow?.Cells?.[1]) {
27 return extractAmount(summaryRow.Cells[1]);
28 }
29 }
30 }
31 return 0;
32}
33
34export async function GET(request: NextRequest) {
35 const { searchParams } = new URL(request.url);
36 const fromDate = searchParams.get('fromDate') ?? new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
37 const toDate = searchParams.get('toDate') ?? new Date().toISOString().split('T')[0];
38
39 try {
40 const { xero, tenantId } = await getAuthenticatedXeroClient();
41
42 const response = await xero.accountingApi.getReportProfitAndLoss(
43 tenantId,
44 fromDate,
45 toDate,
46 undefined, // periods
47 undefined, // timeframe
48 undefined, // trackingCategoryID
49 undefined, // trackingCategoryID2
50 undefined, // trackingOptionID
51 undefined, // trackingOptionID2
52 true // standardLayout
53 );
54
55 const report = response.body.reports?.[0];
56 const rows = (report?.rows as ReportRow[]) ?? [];
57
58 const revenue = findSectionTotal(rows, 'Income');
59 const costOfSales = findSectionTotal(rows, 'Less Cost of Sales');
60 const grossProfit = revenue - costOfSales;
61 const expenses = findSectionTotal(rows, 'Less Operating Expenses');
62 const netProfit = grossProfit - expenses;
63
64 return NextResponse.json({
65 period: { fromDate, toDate },
66 summary: {
67 revenue,
68 costOfSales,
69 grossProfit,
70 operatingExpenses: expenses,
71 netProfit,
72 grossMargin: revenue > 0 ? ((grossProfit / revenue) * 100).toFixed(1) : '0',
73 netMargin: revenue > 0 ? ((netProfit / revenue) * 100).toFixed(1) : '0',
74 },
75 reportTitle: report?.reportName,
76 });
77 } catch (error) {
78 const message = error instanceof Error ? error.message : 'Failed to fetch report';
79 return NextResponse.json({ error: message }, { status: 500 });
80 }
81}

Pro tip: Xero report row structures vary slightly between report types and between organizations depending on their chart of accounts setup. The section titles (Income, Less Cost of Sales, Less Operating Expenses) may differ if the Xero organization uses custom account categories. Build your parser defensively with fallback values rather than assuming exact section titles.

Expected result: Calling /api/xero/reports/pl with a date range returns a P&L summary with revenue, gross profit, operating expenses, net profit, and calculated margin percentages — ready to display as metric cards in a financial dashboard.

Common use cases

Accounts receivable aging dashboard

Pull all outstanding invoices from Xero and display them in an aging report grouped by 0-30 days, 31-60 days, 61-90 days, and 90+ days overdue. This gives finance teams a clear receivables picture without logging into Xero, and can be embedded into existing internal tools.

Bolt.new Prompt

Create a Next.js app with a Xero integration. Build an accounts receivable dashboard that fetches all AUTHORISED and SUBMITTED invoices from Xero using the Invoices API, calculates aging buckets based on DueDate, and displays the invoices in a sortable table grouped by aging category. Show the total outstanding amount per bucket. Use the xero-node SDK and store credentials in .env.

Copy this prompt to try it in Bolt.new

Contact sync between CRM and Xero

When a customer is created or updated in your app's CRM, automatically sync their details to Xero as a contact. This eliminates manual data entry and ensures your accounting system has current customer information. Use Xero's ContactID to link records between systems.

Bolt.new Prompt

Add Xero contact synchronization to my CRM app. When a customer is created or updated in the database, call the Xero Contacts API to create or update the corresponding contact in Xero. Use the customer's email address to find an existing Xero contact before creating. Map CRM fields: name → Name, email → EmailAddress, phone → Phones[0].PhoneNumber, address → Addresses[0]. Store the Xero ContactID back in our database.

Copy this prompt to try it in Bolt.new

Profit and loss report widget

Embed a summarized P&L report from Xero in an executive dashboard showing revenue, cost of goods sold, gross profit, operating expenses, and net profit for a selected period. Finance leaders see key metrics without needing a Xero login.

Bolt.new Prompt

Build a Xero P&L widget that fetches the ProfitAndLoss report from the Xero Reports API for a user-selected month. Display revenue, cost of goods sold, gross profit, operating expenses, and net profit as styled metric cards. Add a month selector dropdown. Show month-over-month percentage change. Use Next.js API route with xero-node to handle the authenticated request.

Copy this prompt to try it in Bolt.new

Troubleshooting

Xero OAuth callback returns 'Invalid redirect_uri' error after authorization

Cause: The redirect URI in the authorization request does not exactly match one registered in developer.xero.com. Xero performs a strict string match.

Solution: In developer.xero.com under My Apps → your app → Redirect URIs, verify the exact URIs registered. Copy one verbatim into your XERO_REDIRECT_URI environment variable. Check for protocol (http vs https), trailing slashes, and path capitalization differences.

xero-node SDK throws 'Invalid token' error after tokens were previously working

Cause: The access token expired (Xero access tokens last 30 minutes) and the refresh token was not used to get a new one, or the refresh token itself expired or was revoked.

Solution: Xero refresh tokens last up to 60 days if used within that period. Ensure the xero-node SDK can access the stored token set and that offline_access was included in the OAuth scopes. After each API call, save the potentially-refreshed token set back to the cookie or database by checking xero.readTokenSet() and comparing expiry to the stored value.

typescript
1// After each API call, refresh stored tokens if they changed:
2const freshTokenSet = await xero.readTokenSet();
3if (freshTokenSet.expires_at !== storedTokenSet.expires_at) {
4 // Token was refreshed — update stored cookie or database
5 updateStoredTokens(freshTokenSet);
6}

API calls work but return empty arrays or unexpected data

Cause: The wrong tenant ID is being used. If the user authorized multiple Xero organizations, the first tenant in the array may not be the organization they intended to connect.

Solution: Log the full tenants array after authorization to see all connected organizations. Build a tenant selector UI that lets users choose which Xero organization to use. Store the selected tenant ID rather than always defaulting to tenants[0].

typescript
1// Get all tenants after OAuth:
2await xero.updateTenants();
3console.log('Available tenants:', xero.tenants.map(t => ({ id: t.tenantId, name: t.tenantName })));

Rate limit errors (429) when making multiple Xero API calls

Cause: Xero's rate limit is 60 requests per minute per app per tenant. Dashboard pages that make multiple API calls in parallel can quickly exceed this limit.

Solution: Use Promise.all to run independent calls concurrently rather than sequentially, but limit total concurrent calls to 4-5 at a time. Implement caching for data that does not change frequently (contacts, chart of accounts). Add exponential backoff retry logic for 429 responses.

typescript
1// Concurrent but limited requests:
2const [invoicesRes, contactsRes, reportRes] = await Promise.all([
3 fetch('/api/xero/invoices'),
4 fetch('/api/xero/contacts'),
5 fetch('/api/xero/reports/pl'),
6]);

Best practices

  • Always deploy before testing the OAuth flow — Xero's redirect URI must be a publicly accessible HTTPS URL that Bolt's WebContainer cannot provide.
  • Store the full token set including refresh_token in a database for production apps, not just in cookies. Tokens need to survive server restarts and be updatable when refreshed.
  • Request only the Xero scopes your app actually uses — the consent screen shows users every scope you request, and unnecessary permissions reduce user trust and approval rates.
  • Implement a tenant selector UI when building apps that may be used by accountants who manage multiple Xero organizations. Never assume tenants[0] is the right organization.
  • Cache Xero API responses for data that changes infrequently — contacts and chart of accounts are stable. Even a 5-minute cache dramatically reduces API calls and keeps your app within the 60 requests/minute limit.
  • Refresh and persist the xero-node token set after each API call. Xero access tokens last 30 minutes; background refreshes fail silently if you do not save the new token set.
  • Test with Xero's Demo Company which has pre-populated invoices, contacts, and bank transactions. Never use real company data for development testing.

Alternatives

Frequently asked questions

Can I test the Xero integration in Bolt's preview before deploying?

Partially. Once you have valid tokens stored (from a completed OAuth flow on your deployed app), outbound API calls to fetch invoices, contacts, and reports work in the Bolt preview. However, the OAuth authorization flow — where Xero redirects back to your callback URL — requires a deployed HTTPS URL. Complete the auth flow on your deployed app first, then use the obtained tokens for API testing in development.

Does Xero's API support both UK/EU and Australian accounting formats?

Yes. Xero's API is region-aware — the data it returns (tax types, account names, currency defaults) reflects the Xero organization's regional settings. Your app does not need special region handling; the API returns data in the format appropriate for each organization. The only region-specific consideration is payroll, which has separate endpoint paths for AU, NZ, and UK.

How do I handle multiple Xero organizations for accountant clients?

After OAuth, the tenants array contains all organizations the user authorized. Store all tenant IDs and names. Build a dropdown or organization switcher in your UI that sets the active tenant ID. Pass the selected tenant ID to all API calls. This pattern allows accountants to view and manage multiple client organizations without re-authorizing.

How long do Xero API tokens last?

Xero access tokens expire after 30 minutes. Refresh tokens last 60 days from their last use. The xero-node SDK automatically uses the refresh token to obtain new access tokens when the current one expires. If the refresh token also expires (unused for 60 days), the user must re-authorize through the OAuth flow.

Can I create invoices in Xero from my Bolt app?

Yes. Use xero.accountingApi.createInvoices with an Invoice object specifying type (ACCREC for sales), contact ID, line items, dates, and status (DRAFT or AUTHORISED). DRAFT invoices are saved but not approved; AUTHORISED invoices are ready to send. After creating an invoice, use xero.accountingApi.emailInvoice to send it to the contact's email address directly from Xero's infrastructure.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.