Packages
bexio_api_client
2.0.0
2.0.0
1.0.0
0.9.0
0.8.0
0.7.1
0.7.0
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.3
0.5.2
0.5.1
0.5.0
0.4.3
0.4.2
0.4.1
retired
0.4.0
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Bexio API Client for accessing the API described at https://docs.bexio.com.
Current section
Files
Jump to
Current section
Files
bexio_api_client
README.md
README.md
# 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](https://hex.pm/docs/publish), the package can be installed
by adding `bexio_api_client` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:bexio_api_client, "~> 2.0"}
]
end
```
## Requirements:
The API uses [Req](https://github.com/wojtekmach/req/tree/v0.5.0) 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:
```elixir
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:
- `400`/`401` `invalid_grant` and `403` (blacklisted) responses are **permanent**:
requests fail fast with `BexioApiClient.RefreshError` and the token endpoint is
not hit again. Call `BexioApiClient.Req.AccessTokenRefresher.clear_refresh_failure/1`
once you have a new refresh token.
- all other failures (including `429`) back off exponentially with jitter (base
500ms, cap 30s); after 15 consecutive failures (~4.5 minutes of probing),
requests fail fast for 10 minutes, with at most one further attempt per
10-minute window.
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:
```elixir
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).
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{status: integer()}` - HTTP status code (200, 201, 301, etc.)
#### `[:bexio_api_client, :request, :error]`
Emitted when a request completes with an error (status 400+).
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{status: integer()}` - HTTP status code (401, 404, etc.)
#### `[:bexio_api_client, :rate_limit, :hit]`
Emitted when a rate limit (429) response is received from the API.
- **Measurements**: `%{reset_seconds: integer()}` - The number of seconds to wait before retrying (from the `ratelimit-reset` header)
- **Metadata**: `%{has_reset_header: boolean()}` - Whether the `ratelimit-reset` header was present in the response
#### `[:bexio_api_client, :request, :retry]`
Emitted when a request will be retried due to server errors (408, 500, 502, 503, 504).
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{status: integer()}` - HTTP status code that triggered the retry
#### `[:bexio_api_client, :refresh, :failed]`
Emitted when a refresh attempt failed.
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{error_class: :invalid_refresh_token | :client_blacklisted | :transient, failure_count: integer()}`
#### `[:bexio_api_client, :refresh, :backoff]`
Emitted when a request was rejected while a refresh backoff was active (the token
endpoint was not contacted).
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{error_class: atom(), retry_in_ms: integer() | :infinity}`
### Usage Example
```elixir
# 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](https://github.com/beam-telemetry/telemetry_metrics) to aggregate these events into metrics for monitoring dashboards.
## Documentation
Documentation can be generated with [ExDoc](https://github.com/)
and can be found at <https://hexdocs.pm/bexio_api_client>.
## API reference and unofficial OpenAPI specification
bexio does not publish an OpenAPI/Swagger definition (their FAQ says so explicitly).
This repository therefore maintains an **unofficial** reconstruction:
* [`docs/bexio-openapi.json`](docs/bexio-openapi.json) / [`docs/bexio-openapi.yaml`](docs/bexio-openapi.yaml) — an OpenAPI 3.0 description of the bexio API,
reconstructed programmatically from <https://docs.bexio.com>.
* [`docs/UNTRUTHS.md`](docs/UNTRUTHS.md) — places where the official docs and
the real API (or this library) diverge.
The spec is regenerated by `scripts/extract_bexio_api_docs.py`
(pure Python stdlib, downloads the live docs by default; pass `--markdown`
if you also want a human-readable Markdown rendering written to
`docs/reference/`). It is linted and formatted with ruff (config in
`scripts/pyproject.toml`) and run via [uv](https://docs.astral.sh/uv/):
```sh
uv run scripts/extract_bexio_api_docs.py # regenerate the OpenAPI spec
uv run --project scripts ruff check scripts/ # lint
uv run --project scripts ruff format scripts/ # format
```
> [!WARNING]
> This specification is **unofficial** and community-maintained — bexio AG is not
> its author, does not maintain it and is not responsible for it. The authoritative
> source is always <https://docs.bexio.com>. Contact:
> Rico Metzger (@titaniumcoder), <rico.metzger@titaniumcoder.com>, or open an
> issue in this repository.
The library itself is hand-written: the OpenAPI spec is a reference for humans
(and for code generators, should you want to build your own client), **not** the
source of truth for this library. Generated code is not used here because the
spec is young and not yet fully trusted; this may change in the future.
## 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`
5. Create a tag for the new version.
6. Push it, github actions will do the release.