Bexio API Client for Elixir

This is a simple client for the API described at https://docs.bexio.com/. It will support all functionality while also replacing enums with atoms and making the structs more useful in elixir.

Installation

If available in Hex, the package can be installed by adding bexio_api_client to your list of dependencies in mix.exs:

def deps do
  [
    {:bexio_api_client, "~> 1.0"}
  ]
end

Requirements:

The API uses Req for the client access to also handle the exponential back-off required in the documentation.

The retry policy for API requests is the BexioApiClient.Req.RetryPolicy behaviour, implemented by BexioApiClient.Req.RewriteDelay (honors Bexio's ratelimit-reset header, retries transient server errors and transport errors, emits retry telemetry). Provide your own policy via:

config :bexio_api_client, :req_options, retry: &MyApp.RetryPolicy.retry/2

The token endpoint never uses this policy — refresh backoff belongs to BexioApiClient.Req.AccessTokenRefresher.

Refresh tokens

Bexio does not currently rotate refresh tokens — the same refresh token keeps working — and the access token refresher does not manage rotation: it always refreshes with the refresh token you passed to BexioApiClient.new/3. Should Bexio ever enable rotation (a Keycloak realm option), token responses would expose the new token through BexioApiClient.access_token/3; host applications would have to persist it themselves.

Separately, hammering the token endpoint after failing refreshes gets your OAuth client blacklisted by Bexio (403 with {"reason": "blacklisted", "status": "blocked"}, enforced per IP).

Refresh failures are classified and backed off automatically:

Do not wrap client requests in your own retry loops: transient token failures are already backed off, and permanent ones will never succeed — hammering the token endpoint with failing refreshes is exactly what gets an OAuth client blacklisted.

When the refresher gives up on a refresh token — immediately on a permanent failure (invalid_grant, blacklisted) or after 5 consecutive transient failures (5-minute cooldown) — it invokes the optional :on_refresh_gave_up callback once:

req =
  BexioApiClient.new(client_id, client_secret, refresh_token,
    on_refresh_gave_up: {MyApp.Auth, :refresh_gave_up, [user_id]}
  )

The MFA is called as MyApp.Auth.refresh_gave_up(user_id, client_id, error_class) so the host application can react (re-authenticate the user, disable sync jobs, alert) and re-enable the token afterwards with BexioApiClient.Req.AccessTokenRefresher.clear_refresh_failure/1. The callback runs synchronously while the refresh lock is held: keep it fast and do not make requests with the same client from it.

The access token cache lives in the :bexio_api_client_access_tokens ETS table keyed by {client_id, refresh_token}.

The ETS tables are owned by the process that created them and are deleted when that process exits. Call BexioApiClient.Req.AccessTokenRefresher.create_ets_table/0 once from a long-lived process (e.g. in your application's start/2) so the cache and the refresh backoff state live as long as your application. Losing the tables is always safe — tokens are re-fetched and backoff state resets — but the cache silently stops working until they are recreated. Concurrent creation is safe.

BexioApiClient.access_token/3 returns the full token map (%{access_token: ..., expires_in: ..., refresh_token: ..., id_token: ..., scope: ..., token_type: ...}). Every non-success response returns {:error, {:unexpected_response, status, body}} and logs the full Req.Response for diagnostics.

Stopping it

The refresher has no background process — it only runs when a request flows through a client built with BexioApiClient.new/3. To stop it completely, stop making requests through that client (e.g. disable the jobs that use it — :on_refresh_gave_up is the hook that tells you when to do that), or switch to the static API token client BexioApiClient.new/1, which has no refresher attached.

Telemetry

The client emits telemetry events that you can use to monitor API usage, rate limiting, and performance. All events are emitted from the request/retry layer, providing comprehensive visibility into all HTTP interactions.

Available Events

[:bexio_api_client, :request, :success]

Emitted when a request completes successfully (status 200-399).

[:bexio_api_client, :request, :error]

Emitted when a request completes with an error (status 400+).

[:bexio_api_client, :rate_limit, :hit]

Emitted when a rate limit (429) response is received from the API.

[:bexio_api_client, :request, :retry]

Emitted when a request will be retried due to server errors (408, 500, 502, 503, 504).

[:bexio_api_client, :refresh, :failed]

Emitted when a refresh attempt failed.

[:bexio_api_client, :refresh, :backoff]

Emitted when a request was rejected while a refresh backoff was active (the token endpoint was not contacted).

Usage Example

# Track successful requests
:telemetry.attach(
  "bexio-success-metrics",
  [:bexio_api_client, :request, :success],
  fn _event, _measurements, metadata, _config ->
    IO.puts("Successful API call with status #{metadata.status}")
  end,
  nil
)

# Track error requests
:telemetry.attach(
  "bexio-error-metrics",
  [:bexio_api_client, :request, :error],
  fn _event, _measurements, metadata, _config ->
    Logger.warning("API error with status #{metadata.status}")
  end,
  nil
)

# Track rate limiting
:telemetry.attach(
  "bexio-rate-limit-metrics",
  [:bexio_api_client, :rate_limit, :hit],
  fn _event, measurements, metadata, _config ->
    if metadata.has_reset_header do
      IO.puts("Rate limit hit, waiting #{measurements.reset_seconds} seconds")
    else
      IO.puts("Rate limit hit without reset header, using default retry")
    end
  end,
  nil
)

# Track retry attempts
:telemetry.attach(
  "bexio-retry-metrics",
  [:bexio_api_client, :request, :retry],
  fn _event, _measurements, metadata, _config ->
    IO.puts("Request will be retried due to status #{metadata.status}")
  end,
  nil
)

# Track specific error codes like unauthorized
:telemetry.attach(
  "bexio-unauthorized-metrics",
  [:bexio_api_client, :request, :error],
  fn _event, _measurements, metadata, _config ->
    if metadata.status == 401 do
      Logger.warning("Unauthorized API request detected")
    end
  end,
  nil
)

You can also use libraries like TelemetryMetrics to aggregate these events into metrics for monitoring dashboards.

Documentation

Documentation can be generated with ExDoc and can be found at https://hexdocs.pm/bexio_api_client.

Building a release

  1. Update the versions in README.md, and mix.exs
  2. Update the CHANGELOG.md
  3. (optional, recommended) Run mix test and mix dialyzer
  4. Create a tag for the new version.
  5. Push it, github actions will do the release.