> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Xero

> Accounting API, OAuth 2.0 and OpenID Connect, and webhooks.

Stateful Xero emulation of the Accounting API under `/api.xro/2.0`, with Xero's JSON envelope, `/Date(...)/` dates, and `ValidationException` errors. It covers the organisation, the chart of accounts and tax rates, contacts and contact groups, items with tracked inventory, invoices and bills with Xero's status transitions and computed totals, credit notes and allocations, payments, bank transactions, and attachments. Organisations are tenants: every call names one in `xero-tenant-id`, checked against the token's connections. OAuth 2.0 runs through an authorize page that picks a user and the organisations to connect, with PKCE, rotating refresh tokens, custom connections (client credentials), and OpenID Connect. Webhooks pass intent to receive before any event is sent, and every delivery is signed with `x-xero-signature`. The tests drive it with the official [`xero-node`](https://github.com/XeroAPI/xero-node) SDK (20.0.0).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/xero` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `xero` |
| HTTP operations | 87, browsable at `http://localhost:3999/docs/xero` while it runs ([API reference](/api-reference.md)) |
| Vendor spec | [XeroAPI/Xero-OpenAPI](https://github.com/XeroAPI/Xero-OpenAPI) at `5a115f3` |

```bash CLI theme={null}
npx @deploybaseline/cli start --service xero
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const xero = await createEmulator({ service: "xero" });
// xero.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "xero": {
    "organisations": [
      {
        "name": "Demo Company (NZ)",
        "tenant_id": "4a8d6f2e-3b1c-4e5a-9d7f-1c2b3a4d5e6f",
        "contacts": [
          {
            "Name": "Ridgeway University",
            "EmailAddress": "accounts@ridgeway.example"
          }
        ],
        "invoices": [
          {
            "Type": "ACCREC",
            "Contact": {
              "Name": "Ridgeway University"
            },
            "Status": "AUTHORISED",
            "Date": "2026-01-15",
            "DueDate": "2026-02-15",
            "LineItems": [
              {
                "Description": "Consulting",
                "Quantity": 2,
                "UnitAmount": 150,
                "AccountCode": "200"
              }
            ]
          }
        ]
      }
    ],
    "apps": [
      {
        "client_id": "C0FFEE00BA5E11E0000000000000A001",
        "client_secret": "baseline_xero_client_secret_0000000000000000000",
        "name": "Local app",
        "redirect_uris": ["http://localhost:3000/callback"]
      }
    ],
    "access_tokens": [
      {
        "token": "xero-baseline-local-token"
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service xero
```

Xero starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`. A fresh emulator has the organisation `Demo Company (NZ)` (tenant `4a8d6f2e-3b1c-4e5a-9d7f-1c2b3a4d5e6f`), the user `demo@example.com` (who can reach every organisation), and the app `C0FFEE00BA5E11E0000000000000A001` / `baseline_xero_client_secret_0000000000000000000` with redirect URIs `http://localhost:3000/callback` and `http://localhost:5000/callback`.

`xero-node` takes the accounting base URL through `accountingApi.basePath`. It hard codes the identity and connections hosts, so a test that uses `initialize()`, `updateTenants()`, `refreshWithRefreshToken()`, or `getClientCredentialsToken()` has to send `https://identity.xero.com` and `https://api.xero.com` to the emulator (for example with an axios request interceptor and a wrapped `Issuer.discover` from xero-node's own `openid-client`). Calls made with a token you already hold need only `basePath`:

```ts theme={null}
import { XeroClient } from "xero-node";

const xero = new XeroClient({ clientId: "", clientSecret: "", redirectUris: [], scopes: [] });
xero.accountingApi.basePath = `${process.env.XERO_EMULATOR_URL ?? "http://localhost:4000"}/api.xro/2.0`;
xero.setTokenSet({ access_token: "xero-baseline-local-token", token_type: "Bearer" });

const tenantId = "4a8d6f2e-3b1c-4e5a-9d7f-1c2b3a4d5e6f";
const { body } = await xero.accountingApi.getInvoices(tenantId);
```

## URL mapping

| Real Xero URL | Emulator URL |
| - | - |
| `https://api.xero.com/api.xro/2.0/...` | `$XERO_EMULATOR_URL/api.xro/2.0/...` |
| `https://api.xero.com/connections` | `$XERO_EMULATOR_URL/connections` |
| `https://login.xero.com/identity/connect/authorize` | `$XERO_EMULATOR_URL/identity/connect/authorize` |
| `https://identity.xero.com/connect/token` | `$XERO_EMULATOR_URL/connect/token` |
| `https://identity.xero.com/.well-known/openid-configuration` | `$XERO_EMULATOR_URL/.well-known/openid-configuration` (also under `/identity`) |

## Auth

Every API call needs `Authorization: Bearer {access_token}`. Access tokens come from the OAuth flow, from `access_tokens[]` in seed config (fixed tokens that never expire), from `POST /_xero/tokens`, or from the CLI (tokens the CLI accepts reach every organisation with every scope).

* No token: 401 `{"Type":null,"Title":"Unauthorized","Status":401,"Detail":"AuthenticationUnsuccessful","Instance":"<xero-correlation-id>","Extensions":{}}` with `WWW-Authenticate: Bearer`. An unknown, revoked, or expired token answers the same with `Bearer error=invalid_token`.
* `xero-tenant-id` missing, malformed, or not connected to the token: 403 with `Title: "Forbidden"`. Custom connection tokens may leave the header out.
* A token without the endpoint's scope: 401 `Detail: "AuthorizationUnsuccessful"` with `WWW-Authenticate: insufficient_scope`. Broad scopes (`accounting.transactions`, `accounting.contacts`, `accounting.settings`, `accounting.attachments`) and the granular ones (`accounting.invoices`, `accounting.payments`, `accounting.banktransactions`) both work; `.read` scopes allow `GET` only.

### Connections

* `GET /connections` — List connections: the token's connections (`id`, `authEventId`, `tenantId`, `tenantType: "ORGANISATION"`, `tenantName`, `createdDateUtc`, `updatedDateUtc`), filtered with `?authEventId=`
* `DELETE /connections/{id}` — Delete connection: disconnects one (204, or 404)

### Rate limits

Every response carries `xero-correlation-id`, and `X-DayLimit-Remaining`, `X-MinLimit-Remaining`, and `X-AppMinLimit-Remaining` against Xero's limits (60 a minute and 5,000 a day per tenant, 10,000 a minute per app). With `rate_limits.enforce: true` a call past a limit answers 429 with an empty body, `Retry-After`, and `X-Rate-Limit-Problem` (`minute`, `day`, or `appminute`).

## OAuth 2.0 and OpenID Connect

* `GET /identity/connect/authorize` — Authorize: `response_type=code`, `client_id`, `redirect_uri` (must be registered), `scope`, `state`, and optional `nonce` and `code_challenge` with `code_challenge_method=S256` (required for apps without a secret). The page asks which user signs in and, for accounting scopes, which of the user's organisations to connect; each becomes a connection. The callback gets `code`, `scope`, `state`, and `session_state`, or `error` (`invalid_scope`, `access_denied`, `unsupported_response_type`)
* `POST /connect/token` — Get token: client credentials in Basic auth or the body, and the grants `authorization_code` (codes are single use and live 5 minutes), `refresh_token` (rotates; the old token keeps working for `oauth.refresh_grace_seconds`, 30 minutes by default; refresh tokens live 60 days), and `client_credentials` for apps with a `custom_connection`. Access tokens are RS256 JWTs that live 30 minutes, with Xero's claims (`client_id`, `sub`, `xero_userid`, `authentication_event_id`, `global_session_id`, `scope`). With `openid`, an ID token (5 minutes) carries `email`, `given_name`, `family_name`, and `nonce`. A refresh token needs `offline_access`. Errors are 400 `{"error":"invalid_client"}`, `invalid_grant`, `unsupported_grant_type`, `unauthorized_client`, or `invalid_scope`
* `POST /connect/revocation` — Revoke token: with a refresh token, it revokes it and removes the user's connections to the app
* `POST /connect/introspect` — Introspect token
* `GET /connect/userinfo` — Get user info
* `GET /connect/endsession` — End session
* `GET /.well-known/openid-configuration` — OpenID configuration: keys at `/.well-known/openid-configuration/jwks`; both also under `/identity`

## Accounting API

All accounting routes are under `/api.xro/2.0`.

### Organisation

* `GET /Organisation` — Get organisation

### Accounts

* `GET /Accounts` — List accounts
* `GET /Accounts/{id}` — Get account
* `PUT /Accounts` — Create account: one account
* `POST /Accounts/{id}` — Update account
* `DELETE /Accounts/{id}` — Delete account: unused, non-system accounts

New organisations get the standard chart (090 and 091 bank accounts, 200 Sales, 400 to 493 expenses, 610 Accounts Receivable, 630 Inventory, 800 Accounts Payable, 820 tax, 960 Retained Earnings, and more).

### Tax rates

* `GET /TaxRates` — List tax rates: the region's rates (NZ 15% GST, AU 10% GST, UK 20% VAT; the US set is a Baseline simplification)
* `GET /TaxRates/{TaxType}` — Get tax rate

### Contacts

* `POST /Contacts` — Create or update contacts: the same path takes `GET` to list and `PUT` to create
* `GET /Contacts/{id or ContactNumber}` — Get contact
* `POST /Contacts/{id}` — Update contact

Archive with `ContactStatus: "ARCHIVED"` (lists leave archived contacts out unless `includeArchived=true`). Names are unique among active contacts.

### Contact groups

* `PUT /ContactGroups` — Create contact group: `GET` on the same path lists them
* `POST /ContactGroups/{id}` — Update contact group: rename, or `Status: "DELETED"`; `GET` on the same path reads one
* `PUT /ContactGroups/{id}/Contacts` — Add contacts to group
* `DELETE /ContactGroups/{id}/Contacts` — Remove all contacts from group
* `DELETE /ContactGroups/{id}/Contacts/{ContactID}` — Remove contact from group

### Items

* `POST /Items` — Create or update items: the same path takes `GET` to list and `PUT` to create
* `POST /Items/{id or Code}` — Update item: `GET` on the same path reads one
* `DELETE /Items/{id}` — Delete item: 204

Tracked items need an `INVENTORY` asset account and `PurchaseDetails.COGSAccountCode`; `QuantityOnHand` and `TotalCostPool` follow authorised bills and invoices. Selling more than is on hand fails with `Insufficient stock. Items are eligible for backorder.` unless `allowBackorders=true`.

### Invoices

* `POST /Invoices` — Create or update invoices: the same path takes `GET` to list and `PUT` to create
* `POST /Invoices/{id or InvoiceNumber}` — Update invoice: `GET` on the same path reads one

`ACCREC` and `ACCPAY`; `LineAmountTypes` `Exclusive`, `Inclusive`, or `NoTax`; totals, tax, and `AmountDue` computed; automatic `INV-0001` numbers; transitions `DRAFT` to `SUBMITTED`, `AUTHORISED`, or `DELETED`, `SUBMITTED` back to `DRAFT`, `AUTHORISED` to `VOIDED`; paid invoices accept only the fields Xero allows. Line items are updated, added, and removed by `LineItemID`. A contact named only by `Name` is created.

### Credit notes

* `POST /CreditNotes` — Create or update credit notes: the same path takes `GET` to list and `PUT` to create
* `POST /CreditNotes/{id}` — Update credit note: `GET` on the same path reads one
* `PUT /CreditNotes/{id}/Allocations` — Allocate credit note
* `DELETE /CreditNotes/{id}/Allocations/{AllocationID}` — Delete allocation

### Payments

* `POST /Payments` — Create payments: the same path takes `GET` to list and `PUT` to create
* `GET /Payments/{id}` — Get payment
* `POST /Payments/{id}` — Reverse payment: with `{"Status":"DELETED"}`

Payments go to `AUTHORISED` documents from a bank account (or one with `EnablePaymentsToAccount`), may be partial, and may not exceed the amount due; a fully paid invoice turns `PAID`.

### Bank transactions

* `POST /BankTransactions` — Create or update bank transactions: the same path takes `GET` to list and `PUT` to create
* `POST /BankTransactions/{id}` — Update bank transaction: `GET` on the same path reads one

`SPEND` and `RECEIVE`, `Status: "DELETED"` to remove.

### Attachments

Attachments work on invoices, credit notes, bank transactions, contacts, and accounts:

* `GET /{Type}/{id}/Attachments` — List attachments
* `GET /{Type}/{id}/Attachments/{AttachmentID or FileName}` — Get attachment: the file
* `POST /{Type}/{id}/Attachments/{FileName}` — Upload attachment: `PUT` or `POST` with the raw file as the body (`IncludeOnline=true` on invoices and credit notes)

Up to 10 a document and 25 MB each; uploading a name again replaces it.

### Lists and filters

* `where` takes Xero's Dynamic LINQ syntax: `==`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT`, `&&`, `||`, parentheses, nested fields (`Contact.Name`), `.Contains()`, `.StartsWith()`, `.EndsWith()`, `.ToLower()`, `.ToUpper()`, `guid("...")`, and `DateTime(2026, 1, 31)`. String comparisons ignore case. A bad expression answers 400 `QueryParseException` with Dynamic LINQ's message (`No property or field 'X' exists in type 'Invoice'`)
* `order` takes a comma list of fields with `ASC` or `DESC`
* `If-Modified-Since` returns records changed after the time
* `IDs`, `Statuses`, `InvoiceNumbers`, `ContactIDs`, `createdByMyApp`, `includeArchived`, and `searchTerm` (contacts and invoices)
* `page` and `pageSize` (100 by default, up to 1,000) on invoices, contacts, credit notes, bank transactions, and payments, with a `pagination` object (`page`, `pageSize`, `pageCount`, `itemCount`). Invoice and credit note line items come with paging, single reads, and `Statuses` filters. `summaryOnly=true` gives the lightest form
* `unitdp=4` keeps four decimal places on unit amounts

### Writes

`PUT` creates and `POST` creates or updates (matching by ID, or by `ContactNumber`, `InvoiceNumber`, or item `Code`). Bodies may be `{"Invoices":[...]}`, a bare list, or one object. With the default `summarizeErrors=true` one bad element rolls back the whole batch and the answer is 400 `ValidationException` (`ErrorNumber: 10`) with each failed element and its `ValidationErrors`. With `summarizeErrors=false` the good elements are saved and the answer is 200 with `StatusAttributeString` `OK` or `ERROR` on each. Malformed JSON answers 400 `PostDataInvalidException`, and an unknown ID answers 404 with the plain text `The resource you're looking for cannot be found`.

`Idempotency-Key` on a `PUT` or `POST` replays the first answer for six minutes. The same key with a different request answers 400 `Idempotency Key: KEY is used with a different request.`, and a key over 128 characters answers `Idempotency Key: KEY with length N is too long.`

## Webhooks

Give an app a `webhook: { url, key }` in seed config, or `POST /_xero/apps/{client_id}/webhook` with `{"url","key"}`. Before the first event the emulator runs intent to receive: one correctly signed and one incorrectly signed empty batch (`{"events":[],"firstEventSequence":0,"lastEventSequence":0,"entropy":"..."}`). The receiver must answer 200 to the first and 401 to the second; a receiver that does not gets no events until intent to receive passes again (`POST /_xero/apps/{client_id}/webhook/intent` runs it now and returns the result).

Each delivery is signed with `x-xero-signature`, the base64 HMAC-SHA256 of the raw body keyed with the webhook key. Changes one API request makes reach each subscribed, connected app as one batch:

```json theme={null}
{
  "events": [
    {
      "resourceUrl": "http://localhost:4000/api.xro/2.0/Invoices/7f1c...",
      "resourceId": "7f1c...",
      "eventDateUtc": "2026-10-20T10:15:03.120",
      "eventType": "CREATE",
      "eventCategory": "INVOICE",
      "tenantId": "4a8d6f2e-3b1c-4e5a-9d7f-1c2b3a4d5e6f",
      "tenantType": "ORGANISATION"
    }
  ],
  "firstEventSequence": 1,
  "lastEventSequence": 1,
  "entropy": "QWERTYUIOPASDFGHJKLZ"
}
```

Invoices and contacts send `CREATE` and `UPDATE` (a payment or allocation updates its invoice). A failed delivery is retried right away and then after 1, 4, and 16 seconds (Xero retries every 15 minutes for a day).

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, signature: string, key: string): boolean {
  const expected = createHmac("sha256", key).update(rawBody).digest("base64");
  return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

## Controls

* `POST /_xero/apps/{client_id}/webhook` — Set app webhook: status `PENDING`
* `POST /_xero/apps/{client_id}/webhook/intent` — Run intent to receive
* `POST /_xero/tokens` — Mint token: optional `client_id`, `user` (email), `organisations` (names or tenant IDs), and `scopes` mints tokens and connections without the browser

## Seed config

```json theme={null}
{
  "xero": {
    "organisations": [
      {
        "name": "Demo Company (NZ)",
        "tenant_id": "4a8d6f2e-3b1c-4e5a-9d7f-1c2b3a4d5e6f",
        "country_code": "NZ",
        "contacts": [
          {
            "Name": "Ridgeway University",
            "EmailAddress": "accounts@ridgeway.example"
          }
        ],
        "items": [
          {
            "Code": "WIDGET",
            "Name": "Widget",
            "SalesDetails": {
              "UnitPrice": 20,
              "AccountCode": "200"
            }
          }
        ],
        "invoices": [
          {
            "Type": "ACCREC",
            "Contact": {
              "Name": "Ridgeway University"
            },
            "Status": "AUTHORISED",
            "Date": "2026-01-15",
            "DueDate": "2026-02-15",
            "LineItems": [
              {
                "Description": "Consulting",
                "Quantity": 2,
                "UnitAmount": 150,
                "AccountCode": "200"
              }
            ]
          }
        ]
      }
    ],
    "users": [
      {
        "email": "alice@example.com",
        "first_name": "Alice",
        "last_name": "Accountant",
        "organisations": ["Demo Company (NZ)"]
      }
    ],
    "apps": [
      {
        "client_id": "C0FFEE00BA5E11E0000000000000A001",
        "client_secret": "baseline_xero_client_secret_0000000000000000000",
        "name": "Local app",
        "redirect_uris": ["http://localhost:3000/callback"],
        "webhook": {
          "url": "http://localhost:3000/xero/webhooks",
          "key": "baseline-xero-webhook-key"
        }
      },
      {
        "client_id": "PKCE0000000000000000000000000001",
        "public": true,
        "redirect_uris": ["http://localhost:3000/callback"]
      },
      {
        "client_id": "CUSTOM00000000000000000000000001",
        "client_secret": "custom-connection-secret",
        "custom_connection": {
          "organisation": "Demo Company (NZ)",
          "scopes": ["accounting.transactions", "accounting.contacts"]
        }
      }
    ],
    "access_tokens": [
      {
        "token": "xero-baseline-local-token"
      }
    ],
    "rate_limits": {
      "enforce": false,
      "per_minute": 60,
      "per_day": 5000,
      "app_per_minute": 10000
    },
    "oauth": {
      "refresh_grace_seconds": 1800
    }
  }
}
```

* `country_code`: NZ, AU, UK, or US: currency, tax rates, timezone

Records are Xero JSON saved through the same validation as the API, in the order accounts, contacts, items, invoices, credit notes, payments, bank transactions. Seeding is idempotent. `signing_key: { private_jwk }` keeps token signatures stable across fresh starts.

## Inspector

`GET /_inspector` shows organisations and users, contacts, invoices and credit notes, payments and bank transactions, apps and connections, and webhook deliveries.

## Limits

Payroll, Assets, Projects, Files, Bank Feeds, and Finance APIs; quotes, purchase orders, receipts, expense claims, manual journals, overpayments and prepayments, batch payments, repeating invoices, reports, history and notes, tracking categories, branding themes, currencies other than the base currency, online invoice links, and PDFs.

## Conformance

| | |
| - | - |
| Routes | 87 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | fail, 1 allowed ([XeroAPI/Xero-OpenAPI](https://github.com/XeroAPI/Xero-OpenAPI) at `5a115f3`) |
| SDKs tested | `jose@6.2.12`, `openid-client@6.8.8`, `xero-node@20.0.0` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.