Skip to main content
RapidDev - Software Development Agency
bubble-integrationsBubble API Connector

Xero

Connect Bubble to Xero's accounting API using OAuth 2.0. Two steps kill most first implementations: after OAuth completes you must call GET /connections to retrieve the tenant ID, and that tenant ID must appear as a Xero-tenant-id header on every subsequent request. Miss this step and every data endpoint returns 403. Tokens also expire in 30 minutes — much faster than most OAuth providers.

What you'll learn

  • How to build the Xero OAuth 2.0 Authorization Code flow in Bubble with a callback page
  • Why GET /connections is non-optional and how to store the tenantId immediately after OAuth
  • How to configure Bubble API Connector with both the Private Authorization header and the Xero-tenant-id header
  • How to parse Xero's non-standard /Date(timestamp)/ date format in Bubble
  • How to build a 25-minute token refresh Backend Workflow given Xero's 30-minute expiry
  • How to use Xero's Demo Company for sandbox testing without a separate sandbox URL
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate18 min read3–5 hoursFinance & AccountingLast updated July 2026RapidDev Engineering Team
TL;DR

Connect Bubble to Xero's accounting API using OAuth 2.0. Two steps kill most first implementations: after OAuth completes you must call GET /connections to retrieve the tenant ID, and that tenant ID must appear as a Xero-tenant-id header on every subsequent request. Miss this step and every data endpoint returns 403. Tokens also expire in 30 minutes — much faster than most OAuth providers.

Quick facts about this guide
FactValue
ToolXero
CategoryFinance & Accounting
MethodBubble API Connector
DifficultyIntermediate
Time required3–5 hours
Last updatedJuly 2026

Xero + Bubble: the tenant ID step that most tutorials skip

Xero is the go-to accounting platform for businesses in Australia, New Zealand, and the UK. Connecting it to Bubble gives you access to invoice management, contact data, bank transaction history, and detailed financial reports — all from within your Bubble app.

The integration uses standard OAuth 2.0 Authorization Code flow, which Bubble handles well with a callback page and Backend Workflows. But there is a mandatory post-OAuth step that most tutorials skip, and skipping it means every single data request returns 403 Forbidden with no helpful error message explaining why.

**The tenant ID step.** After the OAuth token exchange completes, you must immediately call `GET https://api.xero.com/connections` with the new access token. This endpoint returns the list of Xero organizations (tenants) the user has authorized. You need the `tenantId` field from this response. Once stored, it must be sent as the `Xero-tenant-id` header on every data endpoint call — GET /Invoices, GET /Contacts, POST /Invoices, everything. Without it, Xero returns 403 regardless of how valid your access token is.

**The 30-minute clock.** Xero's access tokens expire in 30 minutes — half the duration of QuickBooks' tokens. For Bubble workflows that make multiple sequential Xero calls, refresh the token at the start of the workflow (not between each call) to avoid mid-workflow expiry. Unlike QuickBooks, Xero's refresh tokens do NOT rotate on use — the same refresh token remains valid for 60 days of inactivity, making the token management simpler once you handle the short access token window.

This tutorial covers both gotchas with concrete Bubble implementation steps.

Integration method

Bubble API Connector

REST calls to api.xero.com with Authorization: Bearer [access_token] as a Private header and the mandatory Xero-tenant-id on every data call. OAuth 2.0 Authorization Code flow requires Backend Workflows on a paid Bubble plan for token exchange and the tenant ID bootstrap.

Prerequisites

  • A Bubble account on the Starter plan or higher (Backend Workflows required for OAuth token exchange and tenant ID bootstrap — free plan cannot complete this integration)
  • A Xero account with a live or Demo Company (Xero Starter plan from ~$29/month for production; Demo Company is free for testing)
  • A Xero developer app created at developer.xero.com — free to create
  • API Connector plugin installed in your Bubble app (Plugins → Add plugins → search 'API Connector' → Install)

Step-by-step guide

1

Step 1: Create a Xero developer app and configure your Bubble callback page

Go to developer.xero.com and sign in with your Xero credentials. Click **New app** in the top right. Give it a name (e.g. 'Bubble Integration'), select **Web app** as the integration type, set your company or application name and URL. In the app's OAuth 2.0 credentials section, you will find your **Client ID** and **Client Secret**. Copy both — you will need them for the authorization URL and token exchange. Under **Redirect URIs**, click Add and enter your Bubble callback page URL: `https://your-app.bubbleapps.io/xero-callback` (adjust to your actual app domain). Save the app. In your Bubble editor, create a new page named `xero-callback`. This page will handle the OAuth redirect from Xero. Keep it visually minimal — users see it for only a moment before being redirected to your main dashboard. In the Bubble Data tab, add these fields to the User data type: - `xero_access_token` (text) - `xero_refresh_token` (text) - `xero_tenant_id` (text) - `xero_token_issued_at` (date) Go to the **Privacy** tab, click User, and add a rule: 'This User is Current User' — mark all four xero_ fields as readable only by the owner. This prevents token leakage between users of your app.

xero_user_fields.json
1// Bubble User data type — new fields
2{
3 "xero_access_token": "text",
4 "xero_refresh_token": "text",
5 "xero_tenant_id": "text", // Retrieved from GET /connections after OAuth
6 "xero_token_issued_at": "date" // For 25-minute refresh check
7}
8
9// Privacy rule:
10// When: This User is Current User
11// Read: xero_access_token, xero_refresh_token, xero_tenant_id, xero_token_issued_at

Pro tip: Xero Demo Company: unlike QuickBooks, Xero does not have a separate sandbox URL. For testing, create a Demo Company inside your Xero account (Settings → Subscription → Add organization → Demo company). It uses the same https://api.xero.com base URL as production but points to demo data. Connect your Xero developer app to the Demo Company tenant during testing.

Expected result: A Xero developer app exists with Client ID, Client Secret, and redirect URI to your /xero-callback page. The User data type has four xero_ fields with Privacy rules restricting access to the token owner.

2

Step 2: Build the 'Connect Xero' button and OAuth authorization URL

Add a **Button** element to your main page with the text 'Connect Xero'. Add a workflow to this button with one action: **Navigate → Open an external URL**. The Xero OAuth authorization URL format: `https://login.xero.com/identity/connect/authorize?response_type=code&client_id=[CLIENT_ID]&redirect_uri=[REDIRECT_URI]&scope=openid profile email accounting.transactions accounting.contacts offline_access&state=[STATE]` Key scopes to include: - `accounting.transactions` — read/write invoices, bank transactions, payments - `accounting.contacts` — read/write contacts (customers, suppliers) - `offline_access` — required to receive a refresh token; without this, users must re-authorize every 30 minutes - `openid profile email` — standard OIDC scopes for user identification Build this URL dynamically in Bubble's workflow: 1. Base: `https://login.xero.com/identity/connect/authorize` 2. Append `?response_type=code&client_id=` + your Xero Client ID 3. `&redirect_uri=` + URL-encoded callback page URL 4. `&scope=openid+profile+email+accounting.transactions+accounting.contacts+offline_access` 5. `&state=` + Current User's Unique ID (CSRF protection) The `offline_access` scope is mandatory for the refresh token — if you omit it, the token response from Xero will not include a refresh_token, and users will need to re-authorize every 30 minutes.

xero_oauth_url.json
1// Xero OAuth Authorization URL
2https://login.xero.com/identity/connect/authorize
3 ?response_type=code
4 &client_id=<YOUR_CLIENT_ID>
5 &redirect_uri=https%3A%2F%2Fyour-app.bubbleapps.io%2Fxero-callback
6 &scope=openid+profile+email+accounting.transactions+accounting.contacts+offline_access
7 &state=<Current User Unique ID>
8
9// Xero redirects to callback with:
10https://your-app.bubbleapps.io/xero-callback
11 ?code=ABC123def456
12 &scope=openid+profile+email+accounting.transactions+accounting.contacts+offline_access
13 &session_state=xyz
14 &state=<same_state_value>

Pro tip: If you need access to Xero payroll data, add the `payroll.employees` and `payroll.payruns` scopes. If you need bank feed data, add `bankfeeds`. Only request the scopes you actually need — Xero users see the requested permissions during authorization and excessive scope requests reduce trust.

Expected result: The 'Connect Xero' button opens the Xero authorization page when clicked. Users can log in with their Xero credentials and grant access to your app. After granting, they are redirected to your /xero-callback page.

3

Step 3: Handle the callback — token exchange AND the mandatory /connections call

This is the most important step. The /xero-callback page must do three things in sequence: extract the authorization code, exchange it for tokens, and immediately call GET /connections to retrieve the tenantId. All three must happen in one Backend Workflow to be atomic. Create a Backend Workflow named 'Xero OAuth Complete'. On your xero-callback page, add a workflow 'When page is loaded → Run Backend Workflow → Xero OAuth Complete'. The Backend Workflow steps: **Step 1 — Extract the code:** Use 'Get data from page URL' → parameter name `code`. Store as a local workflow parameter. **Step 2 — Token exchange POST:** - URL: `https://identity.xero.com/connect/token` - Method: POST - Headers: `Authorization: Basic [base64(clientId:clientSecret)]`, `Content-Type: application/x-www-form-urlencoded` - Body: `grant_type=authorization_code&code=[code]&redirect_uri=[your_callback_url]` - Response: access_token, refresh_token (60-day inactivity expiry), expires_in (1800 seconds) **Step 3 — IMMEDIATELY call GET /connections with the new token:** - URL: `https://api.xero.com/connections` - Method: GET - Header: `Authorization: Bearer [access_token from step 2]` - Response: array of connected tenants, each with tenantId, tenantName, tenantType - Extract: `[0].tenantId` (first tenant) or let users choose if they have multiple Xero organizations **Step 4 — Save all three values atomically:** In one 'Make changes to Current User' step: - xero_access_token = token response.access_token - xero_refresh_token = token response.refresh_token - xero_tenant_id = connections response.[0].tenantId - xero_token_issued_at = Current date/time Then navigate to your main dashboard page.

xero_oauth_callback.json
1// Step 1: Token exchange
2{
3 "method": "POST",
4 "url": "https://identity.xero.com/connect/token",
5 "headers": {
6 "Authorization": "Basic <base64(clientId:clientSecret)>",
7 "Content-Type": "application/x-www-form-urlencoded"
8 },
9 "body": "grant_type=authorization_code&code=<code>&redirect_uri=<callback_url>"
10}
11
12// Token response:
13{
14 "access_token": "eyJhbGciOiJSU...",
15 "expires_in": 1800,
16 "token_type": "Bearer",
17 "refresh_token": "Ix8B7O8ndBzMs..."
18}
19
20// Step 2: GET /connections (immediately after)
21{
22 "method": "GET",
23 "url": "https://api.xero.com/connections",
24 "headers": {
25 "Authorization": "Bearer <access_token from above>"
26 }
27}
28
29// Connections response:
30[
31 {
32 "id": "abc123-def456-...",
33 "tenantId": "94e98484-7c28-4f6b-90e9-e8ea1e2e5432",
34 "tenantName": "Demo Company (AU)",
35 "tenantType": "ORGANISATION"
36 }
37]

Pro tip: If a user has multiple Xero organizations connected to their account, the /connections response will have multiple items. Build a step after the callback that shows the user a dropdown of their connected organizations (each with tenantName) and lets them choose which one to connect. Store the selected tenantId.

Expected result: The /xero-callback Backend Workflow: (1) exchanges the OAuth code for tokens, (2) calls GET /connections to retrieve the tenantId, (3) saves access_token, refresh_token, tenant_id, and token_issued_at atomically to the User record. User lands on the dashboard with Xero connected.

4

Step 4: Configure the API Connector with Authorization and Xero-tenant-id headers

In the Bubble editor, go to **Plugins → API Connector**. Click **Add another API** and name it 'Xero'. Set the base URL to `https://api.xero.com/api.xro/2.0`. In the **Shared headers** section, add two headers: **Header 1 — Authorization (Private):** - Name: `Authorization` - Value: `Bearer [access_token]` where `[access_token]` is a parameter placeholder - Check the **Private** checkbox — this prevents the token from appearing in browser requests **Header 2 — Xero-tenant-id:** - Name: `Xero-tenant-id` - Value: `[tenant_id]` where `[tenant_id]` is a parameter placeholder - Do NOT mark this as Private (tenant IDs are identifiers, not secrets) — but DO populate it from the User's stored xero_tenant_id field at call time Add a third shared header: `Accept: application/json`. Without this, Xero returns XML which Bubble cannot parse. Now add the Initialize Call: click **Add a call**, name it 'Get Organisation'. Set method to GET, path to `/Organisation`. Set **Use as: Data**. In the test parameter fields, enter a valid access_token and the stored tenantId from your test user account. Click **Initialize call**. A successful 200 response returns your Xero organization name, country, and financial year settings. If you get 403, the tenant ID is wrong or missing. If you get 401, the access token has expired — you need to run the token refresh workflow first to get a fresh token.

api_connector_xero.json
1// API Connector — Xero group configuration
2{
3 "api_name": "Xero",
4 "base_url": "https://api.xero.com/api.xro/2.0",
5 "shared_headers": [
6 {
7 "name": "Authorization",
8 "value": "Bearer <access_token>",
9 "private": true // Token stays server-side
10 },
11 {
12 "name": "Xero-tenant-id",
13 "value": "<tenant_id>" // NOT private — but populate from User record
14 },
15 {
16 "name": "Accept",
17 "value": "application/json"
18 }
19 ]
20}
21
22// Initialize Call — GET /Organisation:
23{
24 "method": "GET",
25 "path": "/Organisation",
26 "use_as": "Data"
27}
28
29// Expected response:
30{
31 "Organisations": [
32 {
33 "Name": "Demo Company (AU)",
34 "LegalName": "Demo Company (AU)",
35 "CountryCode": "AU",
36 "BaseCurrency": "AUD",
37 "FinancialYearEndDay": 31,
38 "FinancialYearEndMonth": 3
39 }
40 ]
41}

Pro tip: The Xero-tenant-id header value must be a GUID (e.g. 94e98484-7c28-4f6b-90e9-e8ea1e2e5432). Do not confuse it with the tenantName (display name) or the connection id. When populating this header in workflows, use Current User's xero_tenant_id which stores the GUID retrieved from /connections during OAuth.

Expected result: The Xero API Connector group is configured with a Private Authorization header and a Xero-tenant-id header. The 'Get Organisation' Initialize Call returns a 200 with organization name and country. The call is initialized with auto-detected fields.

5

Step 5: Add invoice and contact calls, handle Xero's date format

Add the invoice list call: click **Add a call** in your Xero API group, name it 'Get Invoices'. Set method to GET, path to `/Invoices`. Add query parameters: - `Status`: AUTHORISED (filter to approved invoices; other options: DRAFT, SUBMITTED, VOID, PAID) - `order`: DueDate ASC (sort by due date) - `page`: 1 (Xero uses page-based pagination, 100 records per page) Xero's 60 calls/minute rate limit applies per tenant. For a Repeating Group loading a paginated invoice list, stay well within this limit by caching the results in Bubble's database rather than fetching live on every page load. **Handling Xero's date format.** Xero returns dates in a non-standard format: `/Date(1703548800000+0000)/`. This is not ISO 8601 — it is milliseconds since epoch wrapped in a string. To display it in Bubble, use the following expression chain: 1. Get the date string field from the API response (e.g. `DueDate`: '/Date(1703548800000+0000)/') 2. In Bubble, use `:extract with regex` with the pattern `(\d+)` to extract just the number 3. The number is in milliseconds — divide by 1000 to get a Unix timestamp in seconds 4. Use Bubble's `:formatted as date` to display it For the POST /Invoices call (creating invoices): set method to POST, path to `/Invoices`, Use as: Action. The request body must include Type (ACCREC for accounts receivable invoice), Contact (with ContactID), LineItems array, and DueDate. Add a contact call: GET `/Contacts` with a `searchTerm` parameter for customer lookup when building the invoice creation form.

xero_invoice_calls.json
1// GET /Invoices — call configuration
2{
3 "method": "GET",
4 "path": "/Invoices",
5 "params": {
6 "Status": "AUTHORISED",
7 "order": "DueDate ASC",
8 "page": "1"
9 },
10 "use_as": "Data"
11}
12
13// Xero date format — non-standard:
14// Raw: "/Date(1703548800000+0000)/"
15// Bubble: :extract with regex (\d+) → "1703548800000" → divide by 1000 → formatted as date
16
17// POST /Invoices — create invoice body
18{
19 "Type": "ACCREC",
20 "Contact": {
21 "ContactID": "<contact_id>"
22 },
23 "LineItems": [
24 {
25 "Description": "Consulting services",
26 "Quantity": 1.0,
27 "UnitAmount": 500.00,
28 "AccountCode": "200"
29 }
30 ],
31 "DueDate": "/Date(1703548800000+0000)/",
32 "Status": "AUTHORISED"
33}
34
35// Invoice response item:
36{
37 "InvoiceID": "abc123-def456-...",
38 "InvoiceNumber": "INV-0001",
39 "Total": 500.00,
40 "AmountDue": 500.00,
41 "DueDate": "/Date(1703548800000+0000)/"
42}

Pro tip: Bubble's ':extract with regex' expression can extract the milliseconds from Xero's date string. Use the pattern `[0-9]+` (match one or more digits). This gives you the timestamp string. Then divide by 1000 and use ':formatted as date' with your preferred date format. Build a reusable text formula element for this conversion so you do not repeat the logic in every date field.

Expected result: API calls for GET /Invoices, GET /Contacts, and POST /Invoices are configured and initialized. Xero date strings are being correctly parsed and displayed as readable dates in your Bubble Repeating Group.

6

Step 6: Build the 25-minute token refresh Backend Workflow

Xero access tokens expire in 30 minutes. To avoid mid-workflow 401 errors when a user is actively using your app, build a refresh check that runs at the start of every workflow touching Xero. In Bubble's Backend Workflows, create a new workflow named 'Refresh Xero Token'. This workflow runs as the current user. Workflow steps: 1. **Condition**: Only proceed if `Current User's xero_token_issued_at` is more than 25 minutes ago (5-minute buffer). Use Bubble's date comparison: `xero_token_issued_at < Current date/time - 25 minutes`. 2. **POST to the Xero refresh endpoint:** - URL: `https://identity.xero.com/connect/token` - Headers: `Authorization: Basic [base64(clientId:clientSecret)]`, `Content-Type: application/x-www-form-urlencoded` - Body: `grant_type=refresh_token&refresh_token=[Current User's xero_refresh_token]` 3. **Save the new access_token** (and optionally the refresh_token if Xero issued a new one — Xero does NOT rotate refresh tokens, but include the save in case behavior changes): In one 'Make changes to Current User' step, set xero_access_token to the response access_token and xero_token_issued_at to current date/time. Note: Unlike QuickBooks, Xero's refresh tokens do NOT rotate on each use. The same refresh_token remains valid for 60 days of inactivity. This means a failed access_token save is less catastrophic — you can retry using the same refresh_token. Still, save atomically as a best practice. At the start of every Bubble workflow that calls a Xero endpoint, add 'Run Backend Workflow → Refresh Xero Token' as the first action. This keeps your access tokens fresh automatically. RapidDev's team has built Bubble apps with complex OAuth patterns like this — if you want a free architecture review before going live, visit rapidevelopers.com/contact.

xero_token_refresh.json
1// Xero token refresh POST
2{
3 "method": "POST",
4 "url": "https://identity.xero.com/connect/token",
5 "headers": {
6 "Authorization": "Basic <base64(clientId:clientSecret)>",
7 "Content-Type": "application/x-www-form-urlencoded"
8 },
9 "body": "grant_type=refresh_token&refresh_token=<Current User's xero_refresh_token>"
10}
11
12// Refresh response:
13{
14 "access_token": "eyJhbGciOiJSU...", // NEW access token
15 "expires_in": 1800,
16 "token_type": "Bearer",
17 "refresh_token": "Ix8B7O8ndBzMs..." // Same or new refresh token
18}
19
20// Bubble Backend Workflow condition:
21// When: Current User's xero_token_issued_at < Current date/time - 25 minutes
22// Action: Make changes to Current User
23// xero_access_token = response.access_token
24// xero_token_issued_at = Current date/time
25
26// Note: Xero refresh tokens do NOT rotate — same token valid 60 days inactivity
27// (Compare QuickBooks which invalidates on each use)

Pro tip: Xero refresh tokens expire after 60 days of non-use. If a user has not used your app for 2+ months and their refresh token has expired, the refresh call returns a 400 invalid_grant error. Build a graceful handler: detect this 400 response in your workflow and show the user a 'Please reconnect your Xero account' message that triggers the OAuth flow again.

Expected result: A 'Refresh Xero Token' Backend Workflow exists that checks the 25-minute age threshold and refreshes the access token when needed. All workflows that call Xero data endpoints run this workflow as their first step.

Common use cases

Invoice portal for Xero-based businesses

Let customers log into a Bubble app to view their outstanding invoices from your Xero account, see payment history, and track due dates — without needing access to your full Xero account. Use GET /Invoices with status filters to show only relevant records.

Bubble Prompt

Copy this prompt to try it in Bubble

Automated contact and invoice creation

When a deal closes in your Bubble CRM, automatically create the corresponding Contact and Invoice in Xero via POST /Contacts and POST /Invoices. This eliminates manual re-entry between your operational Bubble app and Xero accounting.

Bubble Prompt

Copy this prompt to try it in Bubble

Financial dashboard with live Xero data

Build an executive dashboard showing total outstanding receivables, monthly invoice volumes, and top customers — all pulled from Xero's API. Use GET /Invoices with date range filters and aggregate in Bubble for display in charts.

Bubble Prompt

Copy this prompt to try it in Bubble

Troubleshooting

Every Xero data endpoint returns 403 Forbidden even with a valid access token

Cause: The Xero-tenant-id header is missing or contains the wrong value. Every Xero data endpoint requires this header — it is not optional. The tenant ID must be the GUID retrieved from GET /connections immediately after OAuth. If the /xero-callback workflow did not include the /connections call, the xero_tenant_id field in the User record is blank, and all data calls return 403.

Solution: Check the User's xero_tenant_id field in Bubble's App Data viewer. If blank, the /connections step was missed. You need to run through the OAuth flow again, this time ensuring the /xero-callback Backend Workflow includes: (1) token exchange, (2) GET https://api.xero.com/connections with the new token, (3) save of tenantId from the connections response. The tenant ID looks like a GUID: 94e98484-7c28-4f6b-90e9-e8ea1e2e5432.

API calls return 401 after 30 minutes of use

Cause: Xero access tokens expire in 30 minutes — significantly faster than most OAuth providers. If no refresh workflow is running, tokens expire during a user session.

Solution: Implement the 'Refresh Xero Token' Backend Workflow from Step 6 and call it at the start of every Xero-touching workflow. Set the refresh trigger to 25 minutes (5-minute buffer before the 30-minute expiry). The refresh POST to https://identity.xero.com/connect/token with grant_type=refresh_token issues a new access token without requiring user interaction.

Xero date fields display as '/Date(1703548800000+0000)/' instead of a readable date

Cause: Xero uses a legacy JavaScript date format (milliseconds since epoch wrapped in /Date(...)/ ) rather than ISO 8601. Bubble does not automatically parse this format.

Solution: In Bubble, parse the date string with two operations: (1) use ':extract with regex' with pattern '[0-9]+' to extract just the millisecond number from the string, (2) the result is a number in milliseconds — divide by 1000, then use ':formatted as date' with your desired display format. Create a reusable text element formula for this transformation to avoid repeating it on every date field.

typescript
1// Bubble expression chain for Xero date:
2// Source: "/Date(1703548800000+0000)/"
3// Step 1: [field]:extract with regex "[0-9]+" → "1703548800000"
4// Step 2: :converted to number → 1703548800000
5// Step 3: / 1000 → 1703548800
6// Step 4: :formatted as date "MM/DD/YY" → "12/26/23"

There was an issue setting up your call — Initialize Call fails for /Invoices or /Contacts

Cause: The Initialize Call needs a real successful response. If either the access_token is expired (30-minute window) or the Xero-tenant-id is wrong or missing in the test parameter fields, the call returns a 403 or 401 and Bubble shows the generic initialization error.

Solution: Ensure you have a fresh access token (less than 25 minutes old) before attempting the Initialize Call. Also verify your tenant ID GUID in the test parameter. Try the endpoint in a REST client like Postman first with the same credentials to confirm the request works before attempting the Bubble Initialize Call. After a successful Initialize, click Save immediately — you only need to initialize once per call unless the response schema changes.

'Workflow API is not enabled' when trying to create Backend Workflows

Cause: Backend Workflows are a paid Bubble feature, not available on the Free plan.

Solution: Upgrade to Bubble Starter plan (~$32/month minimum). Backend Workflows are required for: OAuth token exchange on the /xero-callback page, the GET /connections call after OAuth, and the token refresh workflow. Without them, this integration cannot be completed. Go to Settings → API to check if the 'This app exposes a Workflow API' option is available on your current plan.

Best practices

  • The GET /connections call immediately after OAuth is non-optional — build it as part of the same Backend Workflow as the token exchange so it cannot be accidentally omitted; the tenantId it returns must be stored before the workflow ends.
  • Always check token age against a 25-minute threshold (5 minutes before the 30-minute expiry) at the start of every Xero-touching workflow, and run the refresh workflow proactively rather than reacting to 401 errors mid-workflow.
  • Add Privacy Rules for all xero_ fields in the User data type (Data tab → Privacy → User) immediately — restrict access to 'This User is Current User' to prevent any user from reading another user's Xero tokens through data queries.
  • Cache Xero API responses in Bubble's database and refresh on user action rather than polling continuously — Xero's 60 calls/minute rate limit per tenant is hit easily if a Repeating Group reloads on every page interaction.
  • Unlike QuickBooks, Xero refresh tokens do not rotate on use — the same refresh token remains valid for 60 days of inactivity. This simplifies recovery from failed workflow steps, but still store the new access_token atomically to avoid 401 errors between the refresh call and the save.
  • Use Xero's Demo Company (not a separate sandbox URL) for development — create a Demo Company in your Xero account settings and connect your developer app to that tenant during testing; it uses the same https://api.xero.com URL as production.
  • Build a 'Reconnect Xero' button in your app settings that triggers the OAuth flow — users whose refresh tokens have expired after 60 days of inactivity need a visible, self-service path to re-authorize without contacting support.
  • For Repeating Groups showing invoice lists, add explicit search constraints (Status = AUTHORISED, page = 1) to limit the initial data load; Xero returns up to 100 records per page and multiple pages can add up quickly on WU costs.

Alternatives

Frequently asked questions

Do I need a separate Xero sandbox URL for testing?

No — Xero does not have a separate sandbox URL like QuickBooks. For testing, create a Demo Company inside your Xero account (go to Settings in your Xero account → Add organization → Demo company). The Demo Company is a pre-populated test organization with sample invoices and contacts. Your Xero developer app connects to it using the same https://api.xero.com URL and the same authorization flow. The tenantId from /connections identifies which organization (demo or real) you are working with.

Why do I get 403 on all Xero data calls even with a valid token?

The Xero-tenant-id header is missing. This is the most common first-time implementation failure. After OAuth, every data request must include this header set to the GUID from GET /connections. Check your User record's xero_tenant_id field in Bubble — if it is blank, your callback workflow missed the /connections call. Re-run the OAuth flow with the /connections step included in your callback Backend Workflow.

Does Xero rotate refresh tokens like QuickBooks does?

No — Xero refresh tokens do NOT rotate on each use. The same refresh token remains valid for 60 days of inactivity. Each time you use the refresh token to get a new access token, the 60-day inactivity clock resets. This is simpler to manage than QuickBooks' rotation, but you should still save the new access_token atomically to avoid a window where the old token has expired and the new one hasn't been stored yet.

Can I connect multiple Xero organizations to one Bubble app?

Yes. If a user has multiple Xero organizations, GET /connections returns all of them. Store each tenantId in a Xero Connections database table (with fields: tenantId, tenantName, User). When making API calls, the user can switch between organizations by selecting a different tenantId from their stored connections. This is common for accounting firms managing multiple client organizations.

What is the Xero rate limit and how do I avoid hitting it?

Xero's rate limit is 60 API calls per minute per tenant (verify current limits at developer.xero.com). For most Bubble apps, this is sufficient if you avoid live polling. The key patterns to avoid: don't load invoice data on every page refresh, don't trigger one API call per row in a large Repeating Group, and don't run bulk data sync operations during peak usage hours. Cache Xero data in Bubble's database and refresh only on user-triggered actions or scheduled Background Workflows.

RapidDev

Talk to an Expert

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

Book a free consultation

Integrations are where projects stall

We wire Bubble integrations like this one every week — auth, webhooks, edge cases included. Fixed-price builds from $13K, delivered in 6–10 weeks.

Talk to an integration engineer

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.