Xero OAuth 2.0, built on hot.dev/oauth2.
Flow (authorization code)
- Redirect the user to
authorize-url(...)with the scopes you need (includeoffline_accessto get a refresh token; see::xero/SCOPES_FINANCE_OPS). - In the callback, check
state, thenexchange-code(code, redirect-uri)returns aTokenSet. connections(tokens.access-token)lists the organisations the user authorised; each has atenantIdto send asxero-tenant-id.- Persist the refresh token. Access tokens last 30 minutes.
Rotation. Xero rotates the refresh token on every refresh: the
response carries a new one, and the old one stops working (Xero
allows a 30-minute grace period to retry with it). An unused refresh
token expires after 60 days. Every TokenSet carries the token to
keep in refresh-token; always persist it, or let the
::xero::auth token keeper do it in a ::hot::store. One refresh
token covers every organisation the user connected.
Custom Connections. A Custom Connection is a paid, single-
organisation machine-to-machine app: client-credentials(scopes)
gets a 30-minute token with no refresh token and no tenant header.
Token calls authenticate with HTTP Basic (client_secret_basic).
PKCE apps (no client secret) send client_id in the body instead.
Context Vars
xero.client.idxero.client.secret
Functions
app-credentials
fn (opts: Map, require-secret: Bool): Map
The app's {client-id, client-secret} from opts, else context, with a per-call secret marked so it is masked in call records. The secret may be null for PKCE apps unless require-secret is true. A key in opts that is blank or null is a TokenError, never a fallback to context; a PKCE app leaves client-secret out.
xero.client.id, xero.client.secret
authorize-url
fn (client-id: Str, redirect-uri: Str, scopes: Str | Vec, state: Str): Str
fn (client-id: Str, redirect-uri: Str, scopes: Str | Vec, state: Str, opts: Map): Str
Build Xero's consent URL. scopes is a Vec or space-separated Str
(::xero/SCOPES_FINANCE_OPS is a good start; include
offline_access for a refresh token); state is your CSRF token.
Options: code-verifier (its S256 challenge is sent; keep the
verifier for exchange-code), code-challenge, extra.
url ::xero::oauth/authorize-url("CLIENT-ID", "https://app.example.com/oauth/xero", ::xero/SCOPES_FINANCE_OPS, "csrf-123")
basic-auth
fn (client-id: Str, client-secret: Str): Str
Basic base64(client-id:client-secret) for token calls.
blank-option
fn (opts: Map, names: Vec): Str?
The first of names that opts has but with a blank or null value, else null. Such a key is an Err, never a fallback to context: a tenant whose credential is missing must not run on the project's. Exposed for testing.
build-client-credentials-body
fn (scope: Str): Map
The client_credentials grant body for a Custom Connection; scope may be empty to get every scope the connection was granted.
build-exchange-body
fn (code: Str, redirect-uri: Str, code-verifier: Str?): Map
The authorization_code grant body; code-verifier completes a PKCE flow.
build-refresh-body
fn (refresh-token: Str): Map
The refresh_token grant body.
build-revoke-request
fn (token: Str, client-id: Str, client-secret: Str): ::hot::http/HttpRequest
Build the revocation HttpRequest (RFC 7009: form token=..., Basic client auth). Pure.
build-token-request
fn (body: Map, client-id: Str, client-secret: Str?): ::hot::http/HttpRequest
Build (without sending) the token-endpoint HttpRequest (pure): a
form-encoded grant body, with HTTP Basic client auth when there is a
secret, or client_id in the body for PKCE apps.
callback-params
fn (request: Map): Map
Read Xero's redirect: {code, state, error, error-description} from the request query (null when absent).
client-credentials
fn (scopes: Str | Vec): TokenSet
fn (scopes: Str | Vec, opts: Map): TokenSet
Get a Custom Connection token (client credentials grant). scopes
(Vec or Str) may be empty to receive every scope the connection was
granted. No refresh token; ask again when it expires (30 minutes).
Options: client-id, client-secret.
connections
fn (access-token: Str): Vec
fn (access-token: Str, auth-event-id: Str?): Vec
GET /connections - the organisations (tenants) an access token can
reach: [{id, tenantId, tenantType, tenantName, authEventId, createdDateUtc, updatedDateUtc}]. Send a tenantId as
xero-tenant-id (xero.tenant.id or opts.tenant-id).
auth-event-id (from the access token's authentication_event_id
claim) limits the list to the organisations just connected.
delete-connection
fn (access-token: Str, connection-id: Str): Bool
DELETE /connections/{id} - disconnect one organisation (the connection id, not the tenant id). Returns true, or a TokenError.
exchange-code
fn (code: Str, redirect-uri: Str): TokenSet
fn (code: Str, redirect-uri: Str, opts: Map): TokenSet
Exchange the authorization code for a TokenSet. Options: client-id, client-secret (else context; omit the secret for PKCE apps), code-verifier.
int-or-null
fn (value: Any): Int?
An Int from an Int or digit-only Str; null otherwise. Guards Int() so bad input never halts a run.
is-blank
fn (value: Any): Bool
True for null, "" and whitespace-only strings.
mark-tokens
fn (body: Map): Map
Mask the tokens in a token-endpoint response and return it unchanged. Only the tokens: marking the whole body would also hide values like token_type and scope. Exposed for testing.
normalize-scopes
fn (scopes: Any): Str
Scopes as a space-separated Str from a Vec or a space/comma-separated Str (duplicates removed); "" for none.
now-ms
fn (): Int
refresh
fn (refresh-token: Str): TokenSet
fn (refresh-token: Str, opts: Map): TokenSet
Exchange a refresh token for a new TokenSet (no cache). Xero
returns a new refresh token every time: persist
tokens.refresh-token before using the access token. A reused or
expired refresh token gives error-code: "invalid_grant"; the user
must connect again. Options: client-id, client-secret.
revoke
fn (refresh-token: Str): Bool
fn (refresh-token: Str, opts: Map): Bool
Revoke a refresh token: every connection it authorised is removed. Returns true, or a TokenError.
send-token-request
fn (req: ::hot::http/HttpRequest, prefix: Str): Map
token-error
fn (message: Str): TokenError
fn (prefix: Str, payload: Any): TokenError
Build a TokenError from a message, or from a prefix and a failed token response ({status, body}) or transport error.
token-set
fn (body: Map, sent-refresh-token: Str?, now: Int): TokenSet
Turn a token response into a TokenSet (pure). sent-refresh-token is the token the request used (null otherwise); now is epoch ms.
without-client
fn (body: Map): Map
Types
TokenError
TokenError type {
message: Str,
status: Int?,
error-code: Str?,
error-description: Str?
}
A credential or token-endpoint failure. message is readable, e.g.
"xero: token refresh failed: invalid_grant"; error-code and
error-description carry the OAuth error / error_description.
Token values never appear in messages.
TokenSet
TokenSet type {
access-token: Str,
refresh-token: Str?,
rotated: Bool,
expires-in: Int?,
expires-at: Int?,
token-type: Str?,
id-token: Str?,
scope: Str?
}
The result of a code exchange, refresh or client-credentials grant.
access-token- the bearer token (30 minutes)refresh-token- the refresh token to persist (a new one on every refresh; null for Custom Connections)rotated- true whenrefresh-tokendiffers from the one sentexpires-in,expires-at- lifetime (seconds) and expiry (epoch ms)token-type,id-token(OpenID scopes),scope