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

# Xero

> Client-mode REST API connector for the Xero Accounting API: create and approve invoices from production events, create and update inventory items, filter items, invoices and contacts with where queries, discover the tenant, and get OAuth 2.0 tokens (refresh token or custom connection)

Technical Preview

## Overview

A client-mode connector for the [Xero Accounting API](https://developer.xero.com/documentation/api/accounting/overview) (`api.xro/2.0`). Use it to connect the shop floor to accounting: raise a sales invoice when a batch ships, keep the item catalogue in step with your MES, and read item stock levels, open invoices or customers into MQTT.

The file holds two connectors. `XeroAccountingRestApi` talks to `https://api.xero.com` (accounting calls and tenant discovery), and `XeroOAuth` gets access tokens from Xero's identity host.

| Event | Request | Notes |
| - | - | - |
| GetConnections | `GET /connections` | Lists the organisations (tenants) the token can reach; gives the `tenantId` |
| GetOrganisation | `GET /api.xro/2.0/Organisation` | Smoke and tenant check |
| GetItems | `GET /api.xro/2.0/Items?where=` | Filter items, URL-encoded (for example `IsTrackedAsInventory==true`) |
| GetItem | `GET /api.xro/2.0/Items/{id}` | One item by `ItemID` (the Xero docs also allow the item `Code`); includes `QuantityOnHand` |
| CreateItems | `PUT /api.xro/2.0/Items` | Create only; fails if the `Code` exists |
| UpdateOrCreateItems | `POST /api.xro/2.0/Items` | Create or update (matched on `ItemID` or `Code`) |
| GetInvoices | `GET /api.xro/2.0/Invoices?where=&page=` | Filter invoices, 100 per page with line items |
| GetInvoice | `GET /api.xro/2.0/Invoices/{id}` | One invoice by `InvoiceID` or `InvoiceNumber` |
| CreateInvoices | `PUT /api.xro/2.0/Invoices` | Create only |
| UpdateOrCreateInvoices | `POST /api.xro/2.0/Invoices` | Create or update, for example approve a draft |
| EmailInvoice | `POST /api.xro/2.0/Invoices/{id}/Email` | Emails an approved sales invoice to the contact; **204** with an empty body |
| GetContacts | `GET /api.xro/2.0/Contacts?where=&page=` | Filter customers and suppliers |
| CreateContacts | `PUT /api.xro/2.0/Contacts` | `Name` must be unique |
| RefreshToken | `POST /connect/token` (on `identity.xero.com`) | `refresh_token` grant; new access token and refresh token |
| ClientCredentialsToken | `POST /connect/token` (on `identity.xero.com`) | `client_credentials` grant for custom connections |

Every accounting call sends `Accept: application/json` (Xero answers XML otherwise) and the `xero-tenant-id` header. Topics use `finance/xero/<object>/<verb>`; replies land on `<topic>/response`.

See the base guide: [REST API Connectors](/latest/lot-language/routes/rest-api-routes).

> [!NOTE]
>
> **Auth:** Xero uses OAuth 2.0, in one of two ways.
>
> * **Web app (authorization code).** Do the one-time consent outside the broker, asking for `offline_access` plus the accounting scopes you need, to get a refresh token. Then call GetConnections once to find the organisation's `tenantId`. After that the broker can stay connected on its own: publish to RefreshToken to get a fresh access token (valid 30 minutes).
> * **Custom connection (client credentials).** A custom connection is linked to one organisation and has no refresh token. Publish to ClientCredentialsToken with the scopes to get a 30-minute access token. Custom connections are a paid Xero option, but you can try one for free with the Demo Company.
>
> Every accounting call then takes `access_token` and `tenant_id` from the payload. Keep the client id, client secret and refresh token in Hub secrets (suggested names `XERO_CLIENT_ID`, `XERO_CLIENT_SECRET`, `XERO_BASIC_AUTH`, `XERO_REFRESH_TOKEN`, `XERO_TENANT_ID`). Never commit them into the `.lot`.

> [!WARNING]
>
> The refresh token **rotates**. Each refresh returns a new `refresh_token`, and the old one stops working. Always store the latest value. A refresh token that is not used for 60 days expires, and the user has to connect again.

> [!TIP]
>
> **Scopes.** Xero apps created on or after 2 March 2026 only get granular scopes. For this template ask for `accounting.invoices` (invoices and items), `accounting.contacts` and `accounting.settings` (organisation and items), plus `offline_access` for a refresh token. Older apps can keep the broad `accounting.transactions` scope until September 2027. A call without the right scope fails with **401** or **403**.

## Basic Syntax

```lot wrap theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ROUTE XeroAccountingRestApi WITH TYPE REST_API
    ADD REST_API_CONFIG
        WITH BASE_ADDRESS "https://api.xero.com"
        WITH ENABLE_CLIENT "true"
        WITH USE_SSL "true"
        WITH REQUEST_TIMEOUT '60'
        WITH DEFAULT_CONTENT_TYPE "application/json"
        # Auth: Bearer {value.json.access_token}; tenant {value.json.tenant_id} (from GetConnections). Never commit tokens.
    ADD EVENT GetConnections
        WITH SOURCE_TOPIC "finance/xero/connections/get"
        WITH DESTINATION_TOPIC "finance/xero/connections/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/connections"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
    ADD EVENT GetOrganisation
        WITH SOURCE_TOPIC "finance/xero/organisation/get"
        WITH DESTINATION_TOPIC "finance/xero/organisation/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/api.xro/2.0/Organisation"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
    ADD EVENT GetItems
        WITH SOURCE_TOPIC "finance/xero/item/list"
        WITH DESTINATION_TOPIC "finance/xero/item/list/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/api.xro/2.0/Items?where={value.json.where}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
    ADD EVENT GetItem
        WITH SOURCE_TOPIC "finance/xero/item/get"
        WITH DESTINATION_TOPIC "finance/xero/item/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/api.xro/2.0/Items/{value.json.item_id}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
    ADD EVENT CreateItems
        WITH SOURCE_TOPIC "finance/xero/item/create"
        WITH DESTINATION_TOPIC "finance/xero/item/create/response"
        WITH METHOD "PUT"
        WITH ENDPOINT "/api.xro/2.0/Items"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
        WITH BODY "{value.json.body}"
    ADD EVENT UpdateOrCreateItems
        WITH SOURCE_TOPIC "finance/xero/item/upsert"
        WITH DESTINATION_TOPIC "finance/xero/item/upsert/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/api.xro/2.0/Items"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
        WITH BODY "{value.json.body}"
    ADD EVENT GetInvoices
        WITH SOURCE_TOPIC "finance/xero/invoice/list"
        WITH DESTINATION_TOPIC "finance/xero/invoice/list/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/api.xro/2.0/Invoices?where={value.json.where}&page={value.json.page}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
    ADD EVENT GetInvoice
        WITH SOURCE_TOPIC "finance/xero/invoice/get"
        WITH DESTINATION_TOPIC "finance/xero/invoice/get/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/api.xro/2.0/Invoices/{value.json.invoice_id}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
    ADD EVENT CreateInvoices
        WITH SOURCE_TOPIC "finance/xero/invoice/create"
        WITH DESTINATION_TOPIC "finance/xero/invoice/create/response"
        WITH METHOD "PUT"
        WITH ENDPOINT "/api.xro/2.0/Invoices"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
        WITH BODY "{value.json.body}"
    ADD EVENT UpdateOrCreateInvoices
        WITH SOURCE_TOPIC "finance/xero/invoice/upsert"
        WITH DESTINATION_TOPIC "finance/xero/invoice/upsert/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/api.xro/2.0/Invoices"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
        WITH BODY "{value.json.body}"
    ADD EVENT EmailInvoice
        WITH SOURCE_TOPIC "finance/xero/invoice/email"
        WITH DESTINATION_TOPIC "finance/xero/invoice/email/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/api.xro/2.0/Invoices/{value.json.invoice_id}/Email"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
        WITH BODY "{}"
    ADD EVENT GetContacts
        WITH SOURCE_TOPIC "finance/xero/contact/list"
        WITH DESTINATION_TOPIC "finance/xero/contact/list/response"
        WITH METHOD "GET"
        WITH ENDPOINT "/api.xro/2.0/Contacts?where={value.json.where}&page={value.json.page}"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
    ADD EVENT CreateContacts
        WITH SOURCE_TOPIC "finance/xero/contact/create"
        WITH DESTINATION_TOPIC "finance/xero/contact/create/response"
        WITH METHOD "PUT"
        WITH ENDPOINT "/api.xro/2.0/Contacts"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/json"
            ADD "Authorization" "Bearer {value.json.access_token}"
            ADD "xero-tenant-id" "{value.json.tenant_id}"
        WITH BODY "{value.json.body}"

# OAuth 2.0 token endpoint (different host). basic_auth = base64("<client_id>:<client_secret>") from Hub secrets.
# RefreshToken (auth-code apps): reply {"id_token":..,"access_token":"...","expires_in":1800,"token_type":"Bearer",
#   "refresh_token":"...","scope":"..."}. The refresh token ROTATES: store the returned one every time (60-day expiry).
# ClientCredentialsToken (Custom Connections only): scope is space-separated and URL-encoded by the caller
#   (accounting.invoices%20accounting.contacts%20accounting.settings); reply has access_token + expires_in 1800, no refresh token.
# Errors: 400 invalid_client (bad client id/secret), 400 invalid_grant (refresh token expired/used/revoked), 400 unauthorized_client.
DEFINE ROUTE XeroOAuth WITH TYPE REST_API
    ADD REST_API_CONFIG
        WITH BASE_ADDRESS "https://identity.xero.com"
        WITH ENABLE_CLIENT "true"
        WITH USE_SSL "true"
        WITH REQUEST_TIMEOUT '30'
        WITH DEFAULT_CONTENT_TYPE "application/x-www-form-urlencoded"
    ADD EVENT RefreshToken
        WITH SOURCE_TOPIC "finance/xero/oauth/refresh"
        WITH DESTINATION_TOPIC "finance/xero/oauth/refresh/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/connect/token"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/x-www-form-urlencoded"
            ADD "Authorization" "Basic {value.json.basic_auth}"
        WITH BODY "grant_type=refresh_token&refresh_token={value.json.refresh_token}"
    ADD EVENT ClientCredentialsToken
        WITH SOURCE_TOPIC "finance/xero/oauth/token"
        WITH DESTINATION_TOPIC "finance/xero/oauth/token/response"
        WITH METHOD "POST"
        WITH ENDPOINT "/connect/token"
        WITH HEADERS
            ADD "Accept" "application/json"
            ADD "Content-Type" "application/x-www-form-urlencoded"
            ADD "Authorization" "Basic {value.json.basic_auth}"
        WITH BODY "grant_type=client_credentials&scope={value.json.scope}"
```

## MQTT examples

Control fields (`access_token`, `tenant_id`, ids, `where`, `page`) only fill the URL and headers. Only `body` is sent as the request body.

**RefreshToken** (`finance/xero/oauth/refresh`). `basic_auth` is the Base64 of `client_id:client_secret`. Expect **200**:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{ "basic_auth": "<base64 client_id:client_secret>", "refresh_token": "<latest refresh token>" }
```

Response:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{ "id_token": "eyJhbGciOi...", "access_token": "eyJhbGciOi...", "expires_in": 1800, "token_type": "Bearer", "refresh_token": "a1b2...", "scope": "offline_access accounting.invoices accounting.contacts accounting.settings" }
```

**ClientCredentialsToken** (`finance/xero/oauth/token`), custom connections only. The scope list is space-separated, so URL-encode it:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{ "basic_auth": "<base64 client_id:client_secret>", "scope": "accounting.invoices%20accounting.contacts%20accounting.settings" }
```

**GetConnections** (`finance/xero/connections/get`) needs only the token. Response:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
[ { "id": "7cb59f93-2964-421d-bb5e-a0f7a4572a44", "tenantId": "fe79f7dd-b6d4-4a92-ba7b-538af6289c58", "tenantType": "ORGANISATION", "tenantName": "Demo Company (Global)" } ]
```

**GetItems** (`finance/xero/item/list`). The `where` filter is placed in the URL, so URL-encode it. This asks for tracked inventory items:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<access token>",
  "tenant_id": "fe79f7dd-b6d4-4a92-ba7b-538af6289c58",
  "where": "IsTrackedAsInventory%3D%3Dtrue"
}
```

Response (trimmed):

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{ "Items": [ { "ItemID": "a1b2c3d4-...", "Code": "PUMP-SEAL", "Name": "Pump Seal Kit", "IsTrackedAsInventory": true, "QuantityOnHand": 10.0 } ] }
```

Other filters: `Code=="PUMP-SEAL"` becomes `Code%3D%3D%22PUMP-SEAL%22`.

**UpdateOrCreateItems** (`finance/xero/item/upsert`). Creates the item, or updates it when the `Code` already exists. Expect **200** with the item:

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<access token>",
  "tenant_id": "fe79f7dd-b6d4-4a92-ba7b-538af6289c58",
  "body": {
    "Items": [
      {
        "Code": "PUMP-SEAL",
        "Name": "Pump Seal Kit",
        "IsTrackedAsInventory": true,
        "InventoryAssetAccountCode": "630",
        "PurchaseDetails": { "UnitPrice": 40.0, "COGSAccountCode": "310" },
        "SalesDetails": { "UnitPrice": 75.0, "AccountCode": "200" }
      }
    ]
  }
}
```

A tracked item needs an inventory asset account and a cost of goods sold account. `QuantityOnHand` is read only: stock goes up with approved purchase bills (`ACCPAY`) and down with approved sales invoices (`ACCREC`). Use CreateItems (`PUT`) when a repeated `Code` should fail instead of update.

**CreateInvoices** (`finance/xero/invoice/create`). Bills a customer for two seal kits from batch 4711. Expect **200** with the new invoice (`InvoiceID`, `InvoiceNumber`, `Total`):

```json theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
{
  "access_token": "<access token>",
  "tenant_id": "fe79f7dd-b6d4-4a92-ba7b-538af6289c58",
  "body": {
    "Invoices": [
      {
        "Type": "ACCREC",
        "Contact": { "ContactID": "c0ffee00-0000-4000-8000-000000000058" },
        "Date": "2026-10-05",
        "DueDate": "2026-11-04",
        "LineAmountTypes": "Exclusive",
        "Status": "AUTHORISED",
        "Reference": "Batch 4711",
        "LineItems": [
          { "ItemCode": "PUMP-SEAL", "Description": "Pump Seal Kit", "Quantity": 2, "UnitAmount": 75.0, "AccountCode": "200" }
        ]
      }
    ]
  }
}
```

Leave out `Status` (or send `DRAFT`) to create a draft for review, then approve it later with UpdateOrCreateInvoices: `{"Invoices":[{"InvoiceID":"<id>","Status":"AUTHORISED"}]}`.

**EmailInvoice** (`finance/xero/invoice/email`) takes `invoice_id`. The invoice must be an approved sales invoice and the contact needs an email address. Xero answers **204** with an empty body.

**GetInvoices** and **GetContacts** take `where` and `page` (start at `1`), for example `"where": "Status%3D%3D%22AUTHORISED%22", "page": 1`.

## Keeping the token

A pair of small actions can refresh the token from Hub secrets and keep the latest values when the reply arrives:

```lot theme={"theme":"css-variables","languages":{"custom":["/languages/lot.json"]}}
DEFINE ACTION XeroRefreshFromSecrets
ON EVERY 25 MINUTES DO
    SET "basic" WITH GET SECRET "XERO_BASIC_AUTH"
    SET "refresh" WITH GET SECRET "XERO_REFRESH_TOKEN"
    PUBLISH TOPIC "finance/xero/oauth/refresh" WITH { "basic_auth": {basic}, "refresh_token": {refresh} }

DEFINE ACTION XeroKeepToken
ON TOPIC "finance/xero/oauth/refresh/response" DO
    SET "access" WITH GET JSON "access_token" IN PAYLOAD AS STRING
    SET "refresh" WITH GET JSON "refresh_token" IN PAYLOAD AS STRING
    IF {refresh} != "null" THEN
        KEEP SECRET "XERO_REFRESH_TOKEN" WITH {refresh}
        KEEP TOPIC "finance/xero/session/access_token" WITH {access}
```

Refresh before the access token expires (it lasts 30 minutes), or when a call returns **401** `AuthenticationUnsuccessful`. Restrict who can read the `finance/xero/#` topics with a rule, because these payloads carry tokens and client credentials.

## Webhooks (companion, not packaged)

Xero [webhooks](https://developer.xero.com/documentation/guides/webhooks/overview) `POST` events (for example invoice or contact created or updated) to an HTTPS URL. Each request carries an `x-xero-signature` header, an HMAC-SHA256 of the body with your webhook key, and Xero first checks the URL with an "intent to receive" handshake. Receiving them needs a separate `ENABLE_SERVER` REST API connector on a publicly reachable HTTPS address. That companion connector is not part of this template. Polling GetInvoices with an `UpdatedDateUTC` filter is the alternative.

## Notes

* Limits per organisation: 5 calls at a time, 60 calls per minute and 5,000 calls per day (1,000 per day on Xero's free Starter developer tier). Across all organisations an app can make 10,000 calls per minute. Above that Xero answers **429** with a `Retry-After` header. Each reply carries `X-MinLimit-Remaining` and `X-DayLimit-Remaining`.
* `PUT` and `POST` take up to 50 records per call. By default one invalid record fails the whole request with **400**.
* Common errors: **401** `AuthenticationUnsuccessful` (expired or invalid token, or a missing scope), **403** (the token is not connected to that `tenant_id`), **400** `ValidationException` with the reason under `Elements[].ValidationErrors`, **404** (unknown id), **429** (rate limit).
* Add an `Idempotency-Key` header to a write event if the broker may retry it; Xero then ignores repeats of the same request.
* Not covered: invoice PDF download (binary), attachments, the Payroll, Projects and Assets APIs, disconnecting a tenant (`DELETE /connections/{id}`) and token revoke (`POST https://identity.xero.com/connect/revocation`).
* Suggested branches: `feat/rest-api-route-xero`, `feat/docs-rest-api-xero` (Hugo pushes).

## Related

* [REST API Connectors](/latest/lot-language/routes/rest-api-routes)
* [Xero Accounting API: Invoices](https://developer.xero.com/documentation/api/accounting/invoices)
* [Xero Accounting API: Items](https://developer.xero.com/documentation/api/accounting/items)
* [Xero OAuth 2.0 auth flow](https://developer.xero.com/documentation/guides/oauth2/auth-flow)
* [Xero custom connections](https://developer.xero.com/documentation/guides/oauth2/custom-connections)
* [Xero OpenAPI specs](https://github.com/XeroAPI/Xero-OpenAPI)


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