Xero

Hex.pmDocsCICoverageLicense: MIT

A complete, production-grade Elixir client for all 13 Xero APIs — built with modern Elixir best practices.


Features

FeatureDetails
All 13 Xero APIsAccounting, Assets, Bank Feeds, Files, Finance, Projects, Payroll AU/NZ/UK, Practice Manager v3.1, App Store, eInvoicing
OAuth 2.0Authorization Code, Client Credentials (App Store), token refresh, CSRF-safe state generation, token revocation
Multi-tenant token storeETS-backed TokenStore GenServer with proactive background refresh (5-min pre-expiry)
Connection poolingFinch with configurable pool size and count
Rate limit trackingToken-bucket per tenant (60/min, 5 000/day), auto-respects Retry-After headers
Paginationstream/2 (lazy), fetch_all/2, single-page helpers; auto-paginates all paginated endpoints
Incremental syncif_modified_since: on every applicable endpoint
Telemetry[:xero, :http, :request], [:xero, :rate_limit, :remaining], auth events
Structured errorsTyped Xero.Error structs — never raw maps
Validated configNimbleOptions schema with full documentation
Type constantsXero.Types — all status codes, enumerations, tax types for AU/NZ/UK/US

Installation

# mix.exs
def deps do
[
{:xero, "~> 1.0"}
]
end

Configuration

# config/config.exs
config :xero,
client_id: System.get_env("XERO_CLIENT_ID"),
client_secret: System.get_env("XERO_CLIENT_SECRET"),
redirect_uri: "https://yourapp.com/auth/xero/callback",
scopes: ~w(
openid profile email offline_access
accounting.transactions
accounting.contacts
accounting.reports.read
accounting.settings
accounting.attachments
accounting.budgets.read
assets files projects
)

All Configuration Options

KeyTypeDefaultDescription
client_idstringrequiredXero app Client ID
client_secretstringrequiredXero app Client Secret
redirect_uristringrequiredOAuth callback URL
scopes[string][openid, …]OAuth2 scopes
timeoutinteger30_000Request timeout ms
connect_timeoutinteger10_000TCP connect timeout ms
pool_sizeinteger10Finch pool size
pool_countinteger4Finch pool count
max_retriesinteger3Retry attempts
retry_base_delayinteger1_000Backoff base ms
retry_max_delayinteger30_000Backoff cap ms
log_levelatom:info:debug | :info | :warning | :error | :none

Quick Start

# 1. Generate the OAuth authorization URL
{:ok, url, state} = Xero.Auth.authorize_url()
# → store state in session, redirect user to url
# 2. Exchange code for tokens (in your OAuth callback handler)
{:ok, tokens} = Xero.Auth.fetch_token(params["code"])
# 3. List connected Xero organisations
{:ok, [tenant | _]} = Xero.Auth.connections(tokens)
# 4. Store tokens (auto-refreshed when retrieved)
Xero.Auth.TokenStore.put(user_id, tenant.tenant_id, tokens)
# 5. Use any API
{:ok, tokens} = Xero.Auth.TokenStore.get(user_id, tenant.tenant_id)
{:ok, result} = Xero.Accounting.Invoices.list(tokens, tenant.tenant_id,
statuses: ["AUTHORISED"], page: 1)
{:ok, _} = Xero.Accounting.Invoices.create(tokens, tenant.tenant_id, %{
"Type" => "ACCREC",
"Contact" => %{"ContactID" => "contact-uuid"},
"LineItems" => [%{
"Description" => "Consulting",
"Quantity" => 1.0,
"UnitAmount" => 500.0,
"AccountCode" => "200"
}],
"Status" => "AUTHORISED"
})

All Supported APIs

ModuleAPINotes
Xero.Accounting.InvoicesInvoicesCRUD, email, PDF, stream, online URL, history
Xero.Accounting.ContactsContactsCRUD, stream, CIS (UK), history
Xero.Accounting.AccountsChart of AccountsCRUD, archive
Xero.Accounting.PaymentsPaymentsMulti-currency, stream
Xero.Accounting.BankTransactionsBank Transactionsstream
Xero.Accounting.BankTransfersBank Transfers
Xero.Accounting.BatchPaymentsBatch Payments
Xero.Accounting.CreditNotesCredit NotesAllocations
Xero.Accounting.OverpaymentsOverpaymentsAllocations
Xero.Accounting.PrepaymentsPrepaymentsAllocations
Xero.Accounting.PurchaseOrdersPurchase OrdersPDF, stream
Xero.Accounting.QuotesQuotes
Xero.Accounting.ItemsInventoryTracked & untracked
Xero.Accounting.ManualJournalsManual Journalsstream
Xero.Accounting.JournalsJournalsRead-only
Xero.Accounting.LinkedTransactionsLinked TransactionsBillable expenses
Xero.Accounting.TaxRatesTax Rates
Xero.Accounting.TrackingCategoriesTracking CategoriesOptions CRUD
Xero.Accounting.ContactGroupsContact GroupsMembers
Xero.Accounting.CurrenciesCurrencies
Xero.Accounting.RepeatingInvoicesRepeating Invoices
Xero.Accounting.OrganisationOrganisationActions, CIS
Xero.Accounting.UsersUsers
Xero.Accounting.BrandingThemesBranding ThemesPayment services
Xero.Accounting.BudgetsBudgetsRequires accounting.budgets.read
Xero.Accounting.InvoiceRemindersInvoice Reminders
Xero.Accounting.PaymentServicesPayment Services
Xero.Accounting.HistoryHistory & Notes14 resource types
Xero.Accounting.ReportsReports11 report types
Xero.AssetsAssets APIDepreciation, asset types
Xero.BankFeedsBank Feeds API⚠️ Approval required
Xero.FilesFiles APIUpload, folders, associations
Xero.FinanceFinance APIBank statements, cash validation, financial statements
Xero.ProjectsProjects APITasks, time, project items
Xero.Payroll.AUPayroll AUEmployees, pay runs, timesheets, leave
Xero.Payroll.NZPayroll NZEmployees, pay runs, statutory leave
Xero.Payroll.UKPayroll UKHMRC, NI, working patterns
Xero.PracticeManagerPractice Manager v3.1Jobs, clients, staff, time (XML responses)
Xero.AppStoreApp Store APISubscriptions, metered billing
Xero.EInvoicingeInvoicing APIPEPPOL / UBL 2.1, AU & NZ

Error Handling

case Xero.Accounting.Invoices.list(tokens, tenant_id) do
{:ok, %{"Invoices" => invoices}} ->
handle(invoices)
{:error, %Xero.Error{type: :unauthorized}} ->
# Token expired, auto-refresh failed → re-authenticate user
redirect_to_xero_oauth()
{:error, %Xero.Error{type: :rate_limited, retry_after: secs}} ->
# Handled automatically; this is the manual fallback
Process.sleep(secs * 1_000)
{:error, %Xero.Error{type: :unprocessable, detail: detail}} ->
Logger.error("Xero validation failed: #{inspect(detail)}")
{:error, %Xero.Error{type: :not_found}} ->
{:error, :resource_not_found}
{:error, %Xero.Error{type: :network_error, raw: reason}} ->
Logger.error("Network error: #{inspect(reason)}")
end

Pagination

# Lazy stream — most efficient, only fetches pages as needed
Xero.Accounting.Invoices.stream(tokens, tenant_id, statuses: ["AUTHORISED"])
|> Stream.filter(&(&1["AmountDue"] > 0))
|> Stream.each(&process_invoice/1)
|> Stream.run()
# Fetch all pages (use with caution on large datasets)
{:ok, all_contacts} = Xero.Paginator.fetch_all(
fn opts -> Xero.Accounting.Contacts.list(tokens, tenant_id, opts) end,
key: "Contacts"
)
# Specific page
{:ok, page} = Xero.Accounting.Contacts.list(tokens, tenant_id, page: 3)

Incremental Sync

last_sync = ~U[2024-01-01 00:00:00Z]
# Only fetches records modified since last_sync
Xero.Accounting.Invoices.stream(tokens, tenant_id,
if_modified_since: last_sync)
|> Enum.each(&upsert_invoice/1)

Telemetry

# Attach in Application.start/2
:telemetry.attach_many("my-app-xero", [
[:xero, :http, :request],
[:xero, :rate_limit, :remaining],
[:xero, :auth, :token_refreshed],
[:xero, :auth, :token_refresh_failed]
], &MyApp.XeroTelemetry.handle_event/4, nil)
# telemetry_metrics definitions
[
Telemetry.Metrics.summary("xero.http.request.duration",
unit: {:native, :millisecond},
tags: [:method, :status, :tenant_id]),
Telemetry.Metrics.last_value("xero.rate_limit.remaining.day_remaining"),
Telemetry.Metrics.last_value("xero.rate_limit.remaining.min_remaining"),
Telemetry.Metrics.counter("xero.auth.token_refreshed"),
Telemetry.Metrics.counter("xero.auth.token_refresh_failed")
]

Rate Limits

Xero enforces three tiers — the client tracks all three:

LimitValueTracking
Per tenant / minute60ETS token bucket
Per tenant / day5 000ETS counter
App-wide / minute10 000X-AppMinLimit-Remaining telemetry

Retry-After headers on 429 responses are respected automatically.


Development

mix deps.get
mix test
mix test --cover
mix credo --strict
mix dialyzer
mix docs

License

MIT © 2024 iamkanishka