Xero Kiwi
A Ruby wrapper for the Xero Accounting API. Xero Kiwi handles the unglamorous parts of integrating with Xero — OAuth2, token refresh, rate limiting, retries — so the rest of your code can focus on the actual business problem.
What's in the box
- Full OAuth2 authorization-code flow with PKCE support, CSRF state helpers, and OIDC ID token verification against Xero's JWKS.
- Automatic token refresh with proactive (before expiry) and reactive (on-401) handling, a callback hook for persisting rotated tokens, and a mutex to dedupe concurrent refreshes from multiple threads.
-
Rate-limit-aware retries that honour Xero's
Retry-Afterheader on 429s and back off on transient 5xxs, built onfaraday-retry. - A discoverable client surface with explicit error classes for every failure mode (authentication, rate limit, code exchange, ID token verification, CSRF mismatch).
- Connection management: list and disconnect tenants, with token revocation for "disconnect Xero from my app" flows.
- Accounting resources: fetch contacts, organisations, users, branding themes, and nested objects like addresses, phones, external links, and payment terms — all wrapped in proper value objects.
-
Querying: every list endpoint accepts
where/order/page/page_size/modified_since, with a typed hash DSL (where: { status: "AUTHORISED" }) and raw-string escape hatches. Lazyeach_<resource>helpers walk every page for whole-tenant scans and incremental syncs. -
Resumable syncs:
each_<resource>_pageyields whole pages so you can record the page number alongside the rows, andstart_page:picks the walk back up where it stopped. -
Quota introspection:
client.rate_limit(tenant_id)reports how much of a tenant's daily and per-minute allowance is left, taking the stricter of what Xero reported and what your own throttle bucket holds.
Installation
Add this line to your application's Gemfile:
gem "xero-kiwi"Then run bundle install.
Xero Kiwi requires Ruby 3.4.1 or newer.
Quick start
require "xero_kiwi"
# Once you've completed the OAuth flow and have an access token:
client = XeroKiwi::Client.new(access_token: "ya29...")
client.connections.each do |connection|
puts "#{connection.tenant_name} (#{connection.tenant_type})"
endFor the full OAuth flow, refresh handling, and everything else, see the docs below.
Documentation
| Doc | What it covers |
|---|---|
| Getting started | Installation, the mental model, your first end-to-end request |
| Client |
XeroKiwi::Client — every constructor option, request lifecycle, configuration |
| Connections | Listing tenants, the XeroKiwi::Connection resource, disconnecting tenants |
| Contacts | Listing and fetching contacts, the XeroKiwi::Accounting::Contact resource, nested ContactPerson |
| Contact Groups | Listing and fetching contact groups, the XeroKiwi::Accounting::ContactGroup resource |
| Organisation | Fetching an organisation, the XeroKiwi::Accounting::Organisation resource, nested objects |
| Users | Listing and fetching users, the XeroKiwi::Accounting::User resource, organisation roles |
| Credit Notes | Listing and fetching credit notes, the XeroKiwi::Accounting::CreditNote resource |
| Invoices | Listing and fetching invoices/bills, the XeroKiwi::Accounting::Invoice resource |
| Payments | Listing and fetching payments, the XeroKiwi::Accounting::Payment resource |
| Overpayments | Listing and fetching overpayments, the XeroKiwi::Accounting::Overpayment resource |
| Prepayments | Listing and fetching prepayments, the XeroKiwi::Accounting::Prepayment resource, LineItem |
| Branding Themes | Listing and fetching branding themes, the XeroKiwi::Accounting::BrandingTheme resource |
| Tracking Categories | Listing and fetching tracking categories, the TrackingCategory / TrackingOption / Tracking resources |
| Address | The XeroKiwi::Accounting::Address value object, nested on Organisation and Contact |
| Phone | The XeroKiwi::Accounting::Phone value object, nested on Organisation and Contact |
| ExternalLink | The XeroKiwi::Accounting::ExternalLink value object |
| PaymentTerms | The XeroKiwi::Accounting::PaymentTerms and PaymentTerm value objects |
| Tokens | The XeroKiwi::Token value object, automatic refresh, revocation, persistence callbacks |
| OAuth | Authorization URL building, code exchange, PKCE, ID token verification, full Rails-style example |
| Errors | The error hierarchy, what to catch and when |
| Retries and rate limits | How Xero Kiwi handles 429s and transient failures, customising the retry policy |
| Throttling | Redis-backed token bucket for proactive rate-limit coordination across multiple workers |
| Querying |
where / order / page / page_size / include_archived / modified_since on list endpoints, the Page return type, each_* and each_*_page helpers, and start_page: resumability |
Status
Xero Kiwi is in early development. The API surface for the features documented above is stable, but expect new resource methods to be added over time. Breaking changes will be called out in the changelog.
Development
The gem runs natively via Bundler — nothing is containerised for everyday
development. A docker-compose.yml exists for external services the specs
need, currently just Redis (used by the throttle limiter's Lua-backed specs).
bundle install
docker compose up -d redis # optional; only needed for :redis-tagged specs
bundle exec rspecWithout Redis running, specs tagged :redis are filtered out automatically
(see spec/spec_helper.rb) so the suite still passes.
Override the test Redis URL with TEST_REDIS_URL=... if you don't want the
default redis://127.0.0.1:6379/15.
docker compose down # when you're doneChecking a resource against real payloads
bundle exec rake xero:coverageCompares every recorded response in spec/fixtures/vcr_cassettes against
the resource classes that model it, and reports four things: keys Xero sends
that nothing reads, attributes that were nil in every recording, declared
types that disagree with what arrived, and classes no recording exercises at
all.
Run it whenever you add or change a resource. Every silent bug this gem has shipped would have appeared in one of those four lists, and in each case the disproving payload was already committed — the recordings were never the gap, reading them was.
It reports rather than fails, and deliberately. Gating it would need an allowlist of the legitimately-absent keys, and an allowlist becomes a list nobody reads — which is the failure this is meant to prevent. Read the output instead.
bundle exec rake xero:schemaThe other half: compares the resource classes against Xero's published OpenAPI spec, downloaded and cached for a day. A recording shows only what one tenant happened to populate; the spec lists what an endpoint can return at all.
Run both. Neither is sufficient, and each has caught what the other missed:
| Found by | |
|---|---|
LineItem read AccountId, Xero sends AccountID
|
spec — no recording here carries a line item |
User#global_user_id |
recordings — absent from the spec entirely |
CreditNote#due_date would be a gap |
neither — the spec documents it, Xero sends it on none |
Treat a difference as a question rather than a defect. A payload settles it where one exists.
Releasing
Merge first, then bump. rake release tags whatever is checked out, so a
version bump that rides along inside a pull request gets orphaned the moment
that PR is squash-merged — the squash creates a new commit and the tag is left
pointing at the pre-merge branch commit, unreachable from main. Bumping after
the merge makes that impossible regardless of merge strategy.
So: land the work through a PR as normal, then
git checkout main && git pull- Bump
VERSIONin lib/xero_kiwi/version.rb. - Move the
[Unreleased]entries in CHANGELOG.md under a dated version heading. -
bundle installto refresh the lockfile'sxero-kiwi (x.y.z). -
bundle exec rake— specs, RuboCop, and thellms:checkfreshness guard. - Commit to
main. A commit-msg hook requires thecrimesjohnson:prefix there, so:crimesjohnson: release x.y.z with <the headline>.
bundle exec rake release # builds, tags, pushes the tag, publishesThe gemspec sets rubygems_mfa_required, so this prompts for an OTP and can't
run unattended. Finally, cut the GitHub release from the changelog section:
gh release create vx.y.z --title "vx.y.z — <headline>" --notes-file <(…) --verify-tag --latestNote that docs/ ships inside the gem, so a documentation-only fix still needs
a patch release to reach anyone — it won't arrive through GitHub alone.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/douglasgreyling/xero-kiwi.
License
The gem is available as open source under the terms of the MIT License.