Xero Proxy

Architecture

What happens between an app calling us and Xero answering

The proxy is a transparent, one-to-one mirror of Xero’s API. Same paths, same query strings, same bodies. The only thing that changes on the way in is the credential, and the only thing added is a set of checks that happen before your request ever leaves the platform.

The life of a request

  1. Resolve the key

    The platform key from the Authorization header is looked up by its public key ID, then verified against its stored hash. No table scan, constant-time comparison.

  2. Validate the tenant

    The Xero-Tenant-Id header is checked against the key's allowed orgs. A single-org key can omit it and we fill it in; a multi-org key without it gets a 400 explaining why.

  3. Check the scope

    The method and path are matched against the route → required-scope table. The key must hold that scope, and the connection must still hold it too - we enforce the intersection, not just the key.

  4. Swap the token

    The connection's current Xero access token is attached and the request is forwarded, untouched, to Xero.

  5. Pass the response through

    Status code, body and headers come back verbatim, including Xero's own rate-limit headers, so callers can see their real budget.

The hard part

Token refresh under concurrent load

This is the part of a Xero proxy that breaks naive implementations. A single connection serving many downstream apps means an expired access token gets hit by all of them at once, not one at a time.

Single-flight, per connection

Dozens of downstream apps can hit an expired token in the same instant. Only one refresh call goes to Xero per connection; every other request waits on that result instead of racing it.

Refresh tokens rotate

Every refresh returns a new refresh token and invalidates the old one. A losing request that refreshed independently would kill the whole connection - the lock is what prevents that.

Proactive, not just reactive

Tokens are refreshed ahead of their ~30 minute expiry on a timer, so a live request hitting a stale token is the exception, not the normal path.

Health, tracked and surfaced

Each connection reports healthy, expiring, expired or revoked based on scope and last-refresh time, with a reconnect flow that keeps the same org identity and existing keys.

Enforcement

Deny by default

Every proxied endpoint is keyed on HTTP method and path pattern to a required Xero scope. A GET against an invoices endpoint needs a read scope; a POST against the same path needs the write scope. A route that isn’t in the table is refused, not silently allowed, and the miss is logged so the table can be extended.

Scope failures come back as a 403 that names the scope that was required and the scopes the key actually holds, because a developer integrating against this will hit it while they’re still working out their scope list.

Passthrough

What moves through untouched

  • Query strings, request and response bodies, and Content-Type
  • Accept: JSON, XML, and PDF for report and invoice endpoints
  • If-Modified-Since, pagination and filters
  • Idempotency-Key, which is forwarded and can also be honoured here
  • Multipart uploads and binary downloads, streamed both ways
  • Xero's rate-limit headers, including Retry-After on a 429

See how a key’s scope is actually enforced

Org scope, permission scope, and network scope, checked on every single request.

Review security