Xero

Xero Accounting API 2.0: OAuth 2.0 plus a xero-tenant-id per organisation, ACCREC invoices, contacts, tracked inventory items, payments, credit notes and signed webhooks.

Xero is a New Zealand cloud accounting platform, strongest in New Zealand, Australia and the United Kingdom, and the main competitor to QuickBooks Online in the English-speaking small-business market. Its API is the most pleasant of the accounting APIs in this set to build against: one base URL, a first-party OpenAPI specification maintained in public, consistent where and order filtering on every endpoint, rate-limit headers on every response, and real inventory with QuantityOnHand and QuantityAvailable on tracked items. The two things to internalise before writing code are the tenant model, because a Xero access token is not scoped to an organisation and you must pass xero-tenant-id on every call, and the date format, which is Microsoft .NET epoch milliseconds wrapped in /Date(...)/.

At a glance

What it is

Xero Limited is listed in Australia and headquartered in Wellington. The product is a cloud general ledger with bank feeds, invoicing, payroll in some regions, and an app marketplace of several hundred connected products. Core markets are New Zealand, Australia and the United Kingdom, with a meaningful United States and Canadian presence and a global edition elsewhere.

The domain model is conventional double-entry accounting with a few Xero-specific shapes worth knowing:

  • An Invoice carries a Type that is either ACCREC (sales, accounts receivable) or ACCPAY (a bill, accounts payable). They are the same endpoint and the same object; the type is the only thing separating a sale from a purchase.
  • A Quote is the pre-sale document, the closest thing to a sales order.
  • An Item is a product. Only items with both an InventoryAssetAccountCode and a COGSAccountCode become tracked inventory, at which point IsTrackedAsInventory flips true and the quantity fields appear.
  • A Contact is both customer and supplier, distinguished by IsCustomer and IsSupplier flags rather than by type.
  • Tracking categories are Xero's two-dimensional analysis axes (typically region and department), attached per line item. They are the nearest thing to a location dimension and are not warehouses.

There is no sales order entity, no shipment entity, and no multi-warehouse inventory. Tracked inventory is a single company-wide quantity per item, valued at average cost.

API access

  1. Create an app at developer.xero.com/app/manage and pick the "Auth Code" grant type. You get a client id and can generate a client secret.
  2. Register an HTTPS redirect URI. http://localhost is accepted for testing; http://127.0.0.1 is explicitly not.
  3. Build and test against a demo company in your own Xero account. There is no separate sandbox host.
  4. Connection tiers are the commercial gate. New apps default to the Starter tier with 5 connections. Core raises that to 50. To list on the Xero App Store you need Plus or above, and Xero recommends applying once you have onboarded at least 10 beta customers. Uncertified apps are limited to 25 tenants, and any one organisation may connect at most two uncertified apps, which matters if your target customers already use an uncertified tool.

Base URL: https://api.xero.com/api.xro/2.0. The identity endpoints live on separate hosts: https://login.xero.com/identity/connect/authorize and https://identity.xero.com/connect/token, with the tenant list at https://api.xero.com/connections.

Official SDKs are published by Xero for .NET, Java, Node.js, PHP, Python and Ruby, all generated from the OpenAPI specifications in XeroAPI/Xero-OpenAPI. That repository is the authoritative machine-readable contract and is where the endpoints, filters and sample responses on this page come from. It also holds specifications for the Files, Assets, Bank Feeds, Projects, Finance, App Store, Identity and Payroll APIs, plus xero-webhooks.yaml for the notification contract.

Authentication

OAuth 2.0 authorization code. The distinctive part is that authorisation and tenant selection are separate steps: a token is issued to your app on behalf of a user, and only afterwards do you discover which organisations that user connected.

  1. Send the user to the authorize endpoint. Scopes are space separated.
GET https://login.xero.com/identity/connect/authorize
  ?response_type=code
  &client_id=YOURCLIENTID
  &redirect_uri=https://connector.example.com/oauth/xero
  &scope=openid profile email offline_access accounting.transactions accounting.contacts accounting.settings
  &state=a-unique-per-user-string

offline_access is what gets you a refresh token. Without it the connection dies in 30 minutes. openid profile email gets you an id_token describing the user.

  1. Read the redirect. You get code and your state back. The code may be exchanged once and expires 5 minutes after issue.

  2. Exchange the code with HTTP basic client authentication.

curl -X POST 'https://identity.xero.com/connect/token' \
  -H 'Authorization: Basic <base64(client_id:client_secret)>' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=authorization_code' \
  -d 'code=xxxxxx' \
  -d 'redirect_uri=https://connector.example.com/oauth/xero'

The response carries access_token, expires_in, token_type: Bearer, id_token (if OpenID scopes were requested) and refresh_token (if offline_access was requested).

Token lifetimes, as published: id_token 5 minutes, access_token 30 minutes, refresh_token 60 days. The 30-minute access token is short by the standards of this set and means a refresh loop is mandatory, not optional.

  1. Find the tenants. The access token is a JWT. Decode it for authentication_event_id, then call the connections endpoint.
curl 'https://api.xero.com/connections' \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json'
[
  {
    "id": "32587c85-a9b3-4306-ac30-b416e8f2c841",
    "authEventId": "d0ddcf81-f942-4f4d-b3c7-f98045204db4",
    "tenantId": "e0da6937-de07-4a14-adee-37abfac298ce",
    "tenantType": "ORGANISATION",
    "tenantName": "Adam Demo Company (NZ)",
    "createdDateUtc": "2020-03-23T02:24:22.2328510",
    "updatedDateUtc": "2020-05-13T09:43:40.7689720"
  },
  {
    "id": "74305bf3-12e0-45e2-8dc8-e3ec73e3b1f9",
    "authEventId": "d0ddcf81-f942-4f4d-b3c7-f98045204db4",
    "tenantId": "c3d5e782-2153-4cda-bdb4-cec791ceb90d",
    "tenantType": "PRACTICEMANAGER",
    "tenantName": null
  }
]

Filter by ?authEventId=<authentication_event_id> to see only what was connected in this flow, rather than everything the user has ever connected. tenantType matters: only ORGANISATION tenants serve the Accounting API. If createdDateUtc and updatedDateUtc differ, the user disconnected and reconnected that organisation, which is a useful signal that a full resync may be needed.

  1. Call the API with two headers.
curl 'https://api.xero.com/api.xro/2.0/Invoices?where=Type%3D%3D%22ACCREC%22&page=1' \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Xero-tenant-id: e0da6937-de07-4a14-adee-37abfac298ce' \
  -H 'Accept: application/json'

Omit Accept: application/json and you get XML, because XML is the Accounting API default.

  1. Refresh with grant_type=refresh_token against the same token endpoint and the same basic auth header. The response contains a new refresh token as well as a new access token, and the old refresh token stops working. Persist both on every refresh, transactionally, or you will lose the connection.

Scopes are fine-grained and read and write are separate. The ones that matter here, taken from the OpenAPI security blocks: accounting.transactions and accounting.transactions.read (invoices, credit notes, payments, purchase orders, quotes, bank transactions), accounting.contacts and accounting.contacts.read, accounting.settings and accounting.settings.read (items, accounts, tracking categories, tax rates), plus accounting.journals.read, accounting.reports.read, accounting.attachments, offline_access and the OpenID trio.

Note the asymmetry: items live under accounting.settings, not under transactions. A connector that reads invoices and writes stock needs both families.

Multi-account. One app, one client id. Per connected organisation you store a tenantId, and per user-authorisation you store a token pair. One token pair can serve several tenants, so the cleanest model is a token record keyed on the authorisation, with a many-to-one set of tenants hanging off it.

Objects we can read

Every list endpoint accepts a common set of levers:

  • If-Modified-Since (an HTTP header, UTC timestamp): only records created or modified since then. This is the primary incremental sync mechanism. Xero warns that not every change bumps UpdatedDateUTC: changes to partially paid transactions that do not generate a journal, such as DueDate or SentToContact, and contact fields derived from elsewhere such as Balances, IsSupplier and IsCustomer, will not surface in an If-Modified-Since query.
  • where: a filter expression over any element, for example where=Type=="ACCREC" AND Status=="AUTHORISED".
  • order: an order-by expression over any element.
  • Explicit id and status filters: IDs, InvoiceNumbers, ContactIDs, Statuses, all comma separated. Xero's own guidance is to prefer these to OR conditions in where, for speed.
  • page and pageSize: page size defaults to 100, maximum 1000, minimum 1. Out-of-range values are clamped rather than rejected.
  • summaryOnly=true: a smaller response object with fewer fields, which is faster for a first pass.
  • unitdp=4: four decimal places on unit amounts instead of two.
  • searchTerm: case-insensitive text search across fields such as InvoiceNumber and Reference.
  • createdByMyApp=true: only records your app created, which is a neat way to reconcile your own writes.

Paged responses carry a pagination object:

{
  "Id": "900c500b-e83c-4ce2-902a-b8ba04751748",
  "Status": "OK",
  "DateTimeUTC": "/Date(1552326816230)/",
  "pagination": { "page": 1, "pageSize": 100, "pageCount": 1, "itemCount": 3 },
  "Invoices": [ ]
}

Paging is available on Invoices, Contacts, CreditNotes, BankTransactions, ManualJournals, Payments, PurchaseOrders, Prepayments and Overpayments. Other endpoints return everything in one response.

Warning

JSON dates are .NET format: "DateTimeUTC": "/Date(1439434356790)/" or "Date": "/Date(1539993600000+0000)/". The number is a Unix timestamp in milliseconds. Xero's own documentation calls it ugly and says it cannot be changed without a breaking version. Several fields ship a companion string field, DateString, DueDateString, UpdatedDateUTCString, which is easier to parse. Write a single date coercion helper before anything else.

An unpaginated list of invoices does not include line items. To get line items you must either page (?page=1, which returns full detail for 100 invoices at a time) or read each invoice individually. This is the single biggest driver of API call volume and the reason Xero's own rate-limit guidance leads with it.

Orders

There is no sales order entity. The two candidates:

  • Quote (GET /Quotes) for an order that has been placed but not invoiced, with statuses DRAFT, SENT, DECLINED, ACCEPTED, INVOICED and DELETED.
  • Invoice with Type of ACCREC (GET /Invoices) for the sale itself.
GET /api.xro/2.0/Invoices?where=Type=="ACCREC"&Statuses=AUTHORISED,PAID&page=1&pageSize=100
If-Modified-Since: 2026-09-01T00:00:00
{
  "Type": "ACCREC",
  "InvoiceID": "d4956132-ed94-4dd7-9eaa-aa22dfdf06f2",
  "InvoiceNumber": "INV-0001",
  "Reference": "Red Fish, Blue Fish",
  "Contact": {
    "ContactID": "a3675fc4-f8dd-4f03-ba5b-f1870566bcd7",
    "Name": "Barney Rubble-83203",
    "Addresses": [],
    "Phones": []
  },
  "DateString": "2018-10-20 00:00:00",
  "Date": "/Date(1539993600000+0000)/",
  "DueDateString": "2018-12-30 00:00:00",
  "DueDate": "/Date(1546128000000+0000)/",
  "Status": "AUTHORISED",
  "LineAmountTypes": "Exclusive",
  "LineItems": [],
  "SubTotal": 40.0,
  "TotalTax": 0.0,
  "Total": 40.0,
  "AmountDue": 0.0,
  "AmountPaid": 46.0,
  "AmountCredited": 0.0,
  "CurrencyCode": "NZD",
  "CurrencyRate": 1.0,
  "SentToContact": true,
  "IsDiscounted": false,
  "HasAttachments": false,
  "Payments": [
    {
      "PaymentID": "99ea7f6b-c513-4066-bc27-b7c65dcd76c2",
      "Date": "/Date(1543449600000+0000)/",
      "Amount": 46.0,
      "CurrencyRate": 1.0
    }
  ],
  "CreditNotes": [],
  "Prepayments": [],
  "Overpayments": [],
  "UpdatedDateUTC": "/Date(1541176290160+0000)/",
  "UpdatedDateUTCString": "2018-11-02 16:31:30+00:00"
}

Invoice statuses are DRAFT, SUBMITTED, DELETED, AUTHORISED, PAID and VOIDED. AmountDue, AmountPaid and AmountCredited together describe settlement without a second call, and the nested Payments and CreditNotes arrays give you the links.

LineAmountTypes is Exclusive, Inclusive or NoTax and determines whether UnitAmount on each line already contains tax. Get this wrong and every total is off by the tax rate.

Order items

LineItems on the invoice. Fields from the specification:

"LineItems": [
  {
    "LineItemID": "b18f39d9-7739-4246-9288-72afe939d2d5",
    "ItemCode": "123",
    "Description": "Guitars Fender Strat",
    "Quantity": 1.0,
    "UnitAmount": 148062.76,
    "LineAmount": 148062.76,
    "TaxType": "NONE",
    "TaxAmount": 0.0,
    "AccountCode": "200",
    "Tracking": []
  }
]

Products and listings

GET /Items and GET /Items/{ItemID}, under the accounting.settings scope. Code is the SKU and is user-defined with a maximum length of 30 characters; it is the natural key and is what appears on invoice lines as ItemCode.

{
  "ItemID": "5a70a272-a481-4bcf-a8ca-efffd923ab48",
  "Code": "fg78Ac",
  "Name": "Fender Stratocaster",
  "Description": "Fender Stratocaster Sunburst",
  "PurchaseDescription": "Fender Stratocaster Sunburst",
  "IsSold": true,
  "IsPurchased": true,
  "IsTrackedAsInventory": true,
  "InventoryAssetAccountCode": "140",
  "TotalCostPool": 0.0,
  "QuantityOnHand": 0.0,
  "QuantityAvailable": 0.0,
  "QuantityOnBackOrder": 0.0,
  "SalesDetails": {
    "UnitPrice": 5000.0,
    "AccountCode": "200",
    "TaxType": "OUTPUT2"
  },
  "PurchaseDetails": {
    "UnitPrice": 1500.0,
    "COGSAccountCode": "500",
    "TaxType": ""
  },
  "UpdatedDateUTC": "/Date(1552331874000+0000)/"
}

SalesDetails.UnitPrice is the selling price and PurchaseDetails.UnitPrice the cost. IsSold and IsPurchased control where the item is offered in the UI and are the nearest thing to an active flag.

There is no listing object: no channel id, no URL, no channel-specific price. Price is one number per item, with no price lists or customer tiers on the Accounting API.

Inventory

Only for tracked items, and the semantics are documented precisely:

The mutual exclusion between on-hand and on-backorder is unusual and worth restating: the two are never both positive, and QuantityAvailable goes negative rather than on-hand going negative.

There is no location dimension. Tracking categories are analysis codes on transaction lines, not stock-holding places.

Shipments and tracking

No shipment object, no tracking number field, no carrier. Xero's invoice has InvoiceAddresses (used for e-invoicing and delivery addressing in some regions) but no despatch data. Shipment state must come from a courier connector and be joined on InvoiceNumber or Reference.

Returns and cancellations

Returns are credit notes: GET /CreditNotes, GET /CreditNotes/{CreditNoteID}, under accounting.transactions. A sales credit note has Type of ACCRECCREDIT; a supplier one is ACCPAYCREDIT. Statuses mirror invoices. A credit note carries the same LineItems structure, and the invoices it was allocated against are readable from the invoice side as the CreditNotes array on the invoice.

Cancellation is a status change to VOIDED (for authorised invoices) or DELETED (for drafts), done with POST /Invoices/{InvoiceID} and a body setting Status. Voided invoices remain readable, which is what you want.

Xero also has Prepayments and Overpayments as first-class objects, both of which appear as arrays on the invoice. A marketplace connector will meet overpayments when a customer pays more than the invoice.

Payments and settlements

GET /Payments and GET /Payments/{PaymentID}. A payment links an invoice to a bank account and carries PaymentID, Date, Amount, CurrencyRate, PaymentType, Status, Reference, and references to the Invoice and the Account.

GET /BankTransactions gives money in and out of a bank account independent of invoices, which is where a marketplace payout would land if it was not matched to invoices. There is no settlement object with fee breakdowns.

POST /Payments/{PaymentID} with a Status of DELETED is how a payment is reversed; there is no DELETE verb.

Customers

GET /Contacts, GET /Contacts/{ContactID}, and GET /Contacts/{ContactNumber} for lookup by the seller's own contact number. Under accounting.contacts.

A contact carries ContactID, ContactNumber, ContactStatus (ACTIVE, ARCHIVED, GDPRREQUEST), Name, FirstName, LastName, EmailAddress, Addresses[] (POBOX and STREET types, with AddressLine1 through 4, City, Region, PostalCode, Country), Phones[] (DEFAULT, DDI, MOBILE, FAX), ContactPersons[], IsSupplier, IsCustomer, TaxNumber, DefaultCurrency, PaymentTerms, ContactGroups[] and Balances.

All of the name, email, phone and address data is unmasked PII. ContactStatus of GDPRREQUEST is Xero's marker for a contact whose data has been erased on request; treat it as a deletion signal in your own store.

Locations

Not present as a stock or fulfilment concept. GET /TrackingCategories returns up to two active categories per organisation, each with a set of options, and those options attach to invoice lines as Tracking. If a seller has named a tracking category "Warehouse", that is a convention you can exploit, but it is not a model.

Writing back: sales orders, invoices and stock adjustments

Xero's verb convention is unusual and is the first thing to get right:

  • PUT /<Resource> creates new records only. A PUT against something that already exists fails.
  • POST /<Resource> is create-or-update: the operation ids in the specification are literally updateOrCreateInvoices, updateOrCreateContacts, updateOrCreateItems, updateOrCreateCreditNotes, updateOrCreateQuotes. If the body carries an identifier that matches an existing record, it updates; otherwise it creates.
  • POST /<Resource>/{Id} updates that specific record.

Both the collection-level PUT and POST accept an array, so several invoices can be created in one call. Xero's own rate-limit guidance recommends batching around 50 elements per request, keeping the body under the 3.5 MB practical ceiling (the hard maximum request size is 10 MB).

Invoices

PUT /Invoices to create, POST /Invoices to upsert, POST /Invoices/{InvoiceID} to update one.

{
  "Invoices": [
    {
      "Type": "ACCREC",
      "Contact": { "ContactID": "a3675fc4-f8dd-4f03-ba5b-f1870566bcd7" },
      "Date": "2026-09-22",
      "DueDate": "2026-10-22",
      "InvoiceNumber": "MKT-2026-0417",
      "Reference": "AMZ-402-1234567-8901234",
      "LineAmountTypes": "Exclusive",
      "Status": "AUTHORISED",
      "LineItems": [
        {
          "ItemCode": "fg78Ac",
          "Description": "Fender Stratocaster",
          "Quantity": 1.0,
          "UnitAmount": 5000.0,
          "AccountCode": "200",
          "TaxType": "OUTPUT2"
        }
      ]
    }
  ]
}

Create it as DRAFT and authorise later, or go straight to AUTHORISED. An AUTHORISED invoice posts to the ledger and, if the line references a tracked item, moves stock.

Reference is the field to put the marketplace order id in, and it is both filterable through where and searchable through searchTerm, which makes it a usable idempotency key: query before creating, or rely on InvoiceNumber uniqueness, which Xero enforces (the specification's own error example is "Invoice # must be unique.").

summarizeErrors=false on a batch write tells Xero to return a per-element result rather than failing the whole request, with HasErrors, StatusAttributeString and a ValidationErrors array on each element that failed. Use it for any batch larger than one.

Sales orders

PUT /Quotes and POST /Quotes. A quote has QuoteID, QuoteNumber, Reference, Contact, LineItems, Date, ExpiryDate, Status, Terms, Title and Summary. Move it through its lifecycle with POST /Quotes/{QuoteID} setting Status, and convert it by creating an invoice from the same lines.

Quotes do not reserve stock and do not post to the ledger, which is both the point and the limitation: unfulfilled demand is visible but invisible to inventory.

PurchaseOrders is the inbound equivalent, with PUT /PurchaseOrders, POST /PurchaseOrders and lookup by PurchaseOrderNumber.

Stock adjustments

There is no stock adjustment endpoint, and QuantityOnHand is read-only. Tracked inventory in Xero moves only as the by-product of a transaction:

  • A bill (Type of ACCPAY) with a tracked item line increases quantity and adds to the cost pool.
  • An ACCREC invoice with a tracked item line decreases quantity and posts cost of goods sold.
  • A credit note in either direction reverses the corresponding movement.
  • Opening stock is set when the item is created, by supplying the quantity and value at creation time.

A write-off or a stocktake correction therefore has to be expressed as a transaction. In practice that means an ACCREC invoice to a "Stock adjustment" contact, or a credit note, posting to an adjustment account. There is no way to declare "stock is now 42" and let Xero work out the difference.

Warning

This is the sharpest mismatch between Xero and a marketplace connector. A connector that expects to PUT an absolute quantity cannot do so here. Either accept that Xero's quantity is derived from the transactions you write, and make sure you write every sale and every receipt, or accept that the two systems will diverge and treat Xero as the financial record only, with inventory owned elsewhere.

Untracked items (IsTrackedAsInventory false) carry no quantity at all, so for a seller whose items are untracked the question does not arise.

Items and price

PUT /Items creates, POST /Items upserts, POST /Items/{ItemID} updates one, and DELETE /Items/{ItemID} deletes. Price is SalesDetails.UnitPrice, updatable freely. Converting an item to tracked requires setting InventoryAssetAccountCode and PurchaseDetails.COGSAccountCode, and Xero restricts that conversion once transactions exist against the item.

Webhooks and notifications

Webhooks are configured per app, in the My Apps console, not per organisation. Once configured you receive events for every Xero organisation connected to that app. There is no API to create or manage subscriptions.

Event coverage is narrow. The complete published list:

There are no item or inventory events, and no payment events. Stock changes and payments must be polled.

Body shape, from xero-webhooks.yaml:

{
  "events": [
    {
      "resourceUrl": "https://api.xero.com/api.xro/2.0/Invoices/d4956132-ed94-4dd7-9eaa-aa22dfdf06f2",
      "resourceId": "d4956132-ed94-4dd7-9eaa-aa22dfdf06f2",
      "eventDateUtc": "2026-09-22T09:14:02.000",
      "eventType": "UPDATE",
      "eventCategory": "INVOICE",
      "tenantId": "e0da6937-de07-4a14-adee-37abfac298ce",
      "tenantType": "ORGANISATION"
    }
  ],
  "lastEventSequence": 1,
  "firstEventSequence": 1,
  "entropy": "S0m3r4Nd0mt3xt"
}

The notification carries no entity data, only resourceUrl, so every event costs a follow-up read. firstEventSequence and lastEventSequence let you detect gaps. entropy is a random string included to make the signature harder to forge against an empty body.

Verification and the intent-to-receive handshake. This is the part that trips people up, because a naive receiver will never be enabled at all.

  1. On creating the webhook you get a webhook signing key.
  2. HMAC-SHA256 the raw request body with that key, base64-encode the result, and compare to the x-xero-signature header.
  3. Xero then runs an intent to receive validation: a series of HTTPS POSTs to your URL, some correctly signed and some deliberately not. Your endpoint must return 2xx for correctly signed bodies and 401 Unauthorized for incorrectly signed ones. Returning 2xx for everything fails the validation. Validation takes up to 30 seconds.
  4. Until validation passes, no events are delivered. Changing the delivery URL, or re-enabling a disabled subscription, puts you back into the validation state.

Delivery contract. The endpoint must use HTTPS on port 443, respond within 5 seconds with a 2xx, set no cookies in the response headers, and return 401 on a bad signature. On failure Xero retries immediately, then every 15 minutes, with decreasing frequency, for 24 hours, after which the subscription is disabled and every app collaborator is emailed. Re-enabling is a manual click in the console.

That last point is operationally severe: a receiver that is down for a day takes your webhooks offline for every connected organisation until a human re-enables it. Build the receiver to acknowledge first and process from a queue, and monitor the subscription status.

What to poll. Given the event gaps and the fragility, treat webhooks as a latency optimisation and poll regardless:

  • Items with If-Modified-Since every 15 to 60 minutes, for stock and price.
  • Payments with If-Modified-Since at the same cadence.
  • Invoices and Credit Notes with If-Modified-Since hourly as a safety net behind the webhooks, keeping in mind the documented cases where UpdatedDateUTC does not move.

Rate limits and pagination

Published limits, as of September 2026, all per tenant unless stated:

Xero exposes the remaining budget on every response, which is rare and worth using:

  • X-DayLimit-Remaining
  • X-MinLimit-Remaining
  • X-AppMinLimit-Remaining

On breach you get HTTP 429 with X-Rate-Limit-Problem naming which limit was hit, and, for the minute and daily limits, a Retry-After header in seconds. The windows are fixed and reset at different times per tenant, so the documented instruction is to honour Retry-After rather than guess.

A practical cadence inside 5,000 calls per tenant per day: hourly If-Modified-Since sweeps of Invoices, CreditNotes, Payments, Contacts and Items (about 120 calls per day before paging), webhooks for invoice and contact latency, and paged reads (?page=N&pageSize=100) whenever line items are needed so you are not fetching invoices one at a time. Reserve headroom for writes, and remember that 60 calls per minute and 5 concurrent means a backfill of a large organisation is measured in hours, not minutes.

Mapping to the unified model

Gaps and open questions

  • No absolute stock write. QuantityOnHand is derived and read-only. Any connector that treats Xero as an inventory master has to express every correction as a transaction, which has ledger consequences that need the seller's bookkeeper to agree.
  • No item or payment webhooks. Only Contact, Invoice, Credit Note, Overpayment, Prepayment and App Store Subscription emit events. Stock, items, payments, quotes and purchase orders must be polled.
  • Webhook subscriptions are app-wide and manual. There is no API to create, update or re-enable a subscription. A 24-hour outage of the receiver disables webhooks for every connected organisation until a human clicks re-enable in the console, and there is no programmatic way to detect or fix that. Monitor for the absence of events, not just for errors.
  • If-Modified-Since misses some changes. Xero documents specific cases where UpdatedDateUTC does not move: DueDate and SentToContact changes on partially paid transactions, and derived contact fields such as Balances, IsSupplier and IsCustomer. A periodic full re-read is the only defence, and how often it is needed was not established.
  • Line items need paging or per-record reads. An unpaginated invoice list omits LineItems. Whether summaryOnly=false with paging always returns them for every endpoint that supports paging was not exhaustively checked; it is documented for Invoices.
  • Connection tiering is a commercial gate, not a technical one. Starter at 5 connections and 1,000 calls per tenant per day is not viable for a real connector. The path to Core and above, and its cost, was not established here and needs a conversation with Xero.
  • tenantType values beyond ORGANISATION. PRACTICEMANAGER and XEROHQ tenants appear in GET /connections and do not serve the Accounting API. A connector must filter them or it will send Accounting calls to tenants that cannot answer.
  • XML is the default. Every response is XML unless Accept: application/json is set. A missing header in one code path produces a parse failure that looks like a schema change.

Sources

  • XeroAPI/Xero-OpenAPI, first-party OpenAPI specifications, read directly: xero_accounting.yaml (version 19.0.0) for every endpoint, operation id, scope, query parameter, schema and sample response quoted above, and xero-webhooks.yaml for the webhook event contract
  • OAuth 2.0 standard authorization code flow, Xero Developer: the authorize and token endpoints, the 5-minute code expiry, token lifetimes of 5 minutes, 30 minutes and 60 days, the decoded access token with authentication_event_id, GET /connections and its response, and the two required call headers
  • API limits, Xero Developer: connection tiers, the concurrent, minute, daily and app-minute limits, the X-DayLimit-Remaining, X-MinLimit-Remaining and X-AppMinLimit-Remaining headers, X-Rate-Limit-Problem, Retry-After, and the batching guidance
  • Webhooks overview, Xero Developer: the event category table, the body shape, x-xero-signature, the intent-to-receive validation rules, the 5-second response requirement, and the 24-hour retry then disable behaviour
  • Accounting API HTTP requests and responses, Xero Developer: XML default and the JSON Accept header, the /Date(...)/ format, If-Modified-Since and its documented blind spots, paging with page and pageSize, the 100 default and 1000 maximum, and the pagination object