API reference¶
- Base:
https://<server>/api - Format: JSON in (body) and out. Parameters can also be passed in the query string; body and query string are merged.
- Authentication:
Authorization: Bearer <token>header (JWT obtained fromPOST /user/login) on every route, except the public routes:POST /user/login,GET /bank/sync(JWT orsync_token),POST /mcpandGET /mcp(access token or JWT),GET /health, and the OAuth routes for AI agents (metadata/.well-known/oauth-*,POST /oauth/register,GET /oauth/authorize,POST /oauth/token). A unit test checks this list. - Named parameters: each request key maps to a parameter of the PHP method. An unknown key returns a 400 error.
- Profiles: a user views and checks off transactions, budgets, insights and
trends. An administrator also manages rules, categories, accounts, insights, the
MCP server, users, the configuration and the logs: routes marked
"administrator" return
403to everyone else. They are declared in their attribute (#[ApiRoute(..., admin: true)]) and checked before the method is called; a unit test pins the access level (public, user, administrator) of every route, and Swagger shows it (x-carbure-accessextension).
Errors¶
| Code | Case |
|---|---|
| 400 | Missing, unknown or invalid parameter; invalid JSON |
| 401 | Missing, invalid or expired JWT; invalid credentials; invalid MCP access token |
| 403 | Action restricted to administrators; MCP server disabled; origin rejected; wrong setup code |
| 404 | Route or resource not found |
| 405 | GET /mcp (the MCP server does not open a stream) |
| 409 | Conflict (duplicate, deleting a user who owns transactions, last administrator…) |
| 423 | Account locked after too many failed logins |
| 500 | Server error (database, woob, migration…) |
| 503 | Carbure is not installed yet (see Setup wizard) |
uid identifies the request in data/logs/carbure_YYYYMMDD.log.
Stable code of an error¶
The errors a user meets (validation and conflicts of the rules, categories, insights,
users, login and two-factor authentication, bank connection, settings, alert
thresholds…) add a stable code error_code, and params when the message carries
values. The error message stays in English, unchanged (logs, mobile apps, API clients,
MCP server); code stays the HTTP status.
{ "error": "This keyword already exists", "code": 409, "uid": "6ac106d3912d9", "error_code": "keyword_exists" }
{
"error": "Account locked after too many failed logins, until 2026-10-27 18:30:00: ask an administrator to unlock it",
"code": 423,
"uid": "6ac106d3912d9",
"error_code": "account_locked_until",
"params": { "until": "2026-10-27 18:30:00" }
}
A client translates error_code (the portal: apiErrors.<error_code> of
portal/i18n/<language>.json, where {until} gets the value of params) and shows the
error message when it does not know the code (newer server) or the error has none
(technical error, older server). Codes do not change from one version to the next; an
error may get one in a later version. POST /insight/check likewise gives error_code
and params next to error.
Data changes¶
GET /changes (any logged-in user) gives a change marker per kind of data, bumped
by every write. A client (mobile apps, portal) compares the versions with the ones of the
data it loaded and reloads only the kinds that moved: a light call (one indexed read of a
few rows), to make when coming back to the foreground; then the header of each response
is enough (see below).
{
"transactions": { "version": 1834, "updated_at": "2026-10-24 21:03:12.418" },
"accounts": { "version": 52, "updated_at": "2026-10-24 21:03:12.377" },
"categories": { "version": 17, "updated_at": "2026-10-20 08:15:40.002" },
"budgets": { "version": 96, "updated_at": "2026-10-23 19:42:05.731" },
"insights": { "version": 3, "updated_at": "2026-10-12 10:00:00.000" },
"settings": { "version": 0, "updated_at": null }
}
| Kind | Bumped by |
|---|---|
transactions |
synchronization (new or modified transaction, category found), POST /transaction/import, PUT /transaction/category, PUT /transaction/pointed, DELETE /transaction, rules applied to the history, DELETE /category |
accounts |
synchronization (account in error, repaired or another error), POST/PUT/DELETE /bank, /bank/backend |
categories |
POST/PUT/DELETE /category, /category/keyword, PUT /category/keyword/settings |
budgets |
POST /budget, POST /budget/copy, DELETE /category |
insights |
POST/PUT/DELETE /insight |
settings |
PUT /system/config |
POST /backup/import bumps every kind. The insights and the trends are computed from the
transactions and the budgets: a client reloads them when one of these kinds moves.
- Version, not only a date: no tie between two writes in the same second, no
dependency on the clocks.
updated_atis given for information; a kind never changed has the version0. - A synchronization that changes nothing (unchanged account) bumps nothing: it does not wake the devices.
- Every authenticated response carries the versions of every kind in the
X-Carbure-Changesheader (accounts=52, budgets=97, … transactions=1835): the client checks with each call it makes anyway whether what it loaded is up to date, without polling.GET /changesis for a return to the foreground, when no call is in progress. No header on the public routes nor on the streams. - A write also names the kinds it bumped in
X-Carbure-Changed(budgets): if the known version was the previous one, the client that wrote is up to date without reloading anything; if it moved more, someone else wrote meanwhile. - Each write route declares the kinds it changes in its attribute
(
#[ApiRoute('/budget', method: 'POST', changes: ['budgets'])]), bumped after a successful call; a unit test checks that every write route declares them. - An older server answers
404: the client then reloads at each return to the foreground.
These markers are not in GET /health, which is public: they would tell anyone when the
household uses the app and when the synchronization runs.
Users¶
POST /user/login — public¶
| Parameter | Type | Required | Description |
|---|---|---|---|
username |
string | yes | Username |
password |
string | yes | Password |
device |
object {name, token, platform?, model?, os_version?, app_version?} |
no | Mobile device to register for notifications (see the device object) |
mfa |
boolean | no | The client supports two-factor authentication (true for the portal) |
curl -X POST https://exemple.fr/api/user/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"…","device":{"name":"iPhone16,1","token":"<token APNs>","platform":"ios","model":"iPhone16,1","os_version":"iOS 26.0","app_version":"1.2"}}'
# Android: "device":{"name":"Google Pixel 8","token":"<token FCM>","platform":"android","model":"Google Pixel 8","os_version":"Android 15","app_version":"1.0"}
The token is valid for 30 days. The login date is recorded (last_login).
401 if the credentials are wrong; 423 if the account is locked: after 5 failures in a
row, the account is locked for 24 hours, even with the right password (the 5th failure
already answers 423). A successful
login resets the counter.
Two-factor authentication (see Security):
when it is enforced (mfa_required) or enabled by the user, and neither the device
(device.token already registered for this user) nor the browser (trust cookie) is known,
the response is not the session but a challenge valid for 5 minutes:
mfa is enroll when the user has no authenticator app yet: the response adds
secret (base32) and otpauth (QR code URI). A client that does not send mfa=true
gets 403 (explicit message) instead of the challenge; the response format does not change
for a user without two-factor authentication. A new device is only registered
after the code.
device object¶
Sent by the mobile apps (the portal does not send one); the device is created or updated (user, name, platform, model and versions) at each login.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | no | Device name |
token |
string | yes | Push token (APNs for iOS, FCM for Android); without a token, no device is registered |
platform |
string | no | ios or android (case and surrounding spaces ignored; 400 for any other value). Missing or empty: inferred from the token — 64 hexadecimal characters (APNs) = ios, any other token (FCM: long, with :, -, _) = android |
model |
string | no | Model (iPhone16,1, Google Pixel 8), 100 characters at most |
os_version |
string | no | System version (iOS 26.0, Android 15), 50 characters at most |
app_version |
string | no | App version (1.2), 20 characters at most |
Texts are trimmed and truncated to their maximum length; a missing or empty field is
stored as null. Until the
2026-10-25_device_model migration is applied, model, os_version and app_version are
ignored (the login does not fail).
iOS (the APNs token is 64 hexadecimal characters):
{ "name": "iPhone16,1", "token": "a1b2c3…", "platform": "ios",
"model": "iPhone16,1", "os_version": "iOS 26.0", "app_version": "1.2" }
Android (FCM token):
{ "name": "Google Pixel 8", "token": "fXk9…:APA91b…", "platform": "android",
"model": "Google Pixel 8", "os_version": "Android 15", "app_version": "1.0" }
POST /user/login/mfa — public¶
Second step of the login: exchanges the challenge and the code for the session.
| Parameter | Type | Required | Description |
|---|---|---|---|
mfa_token |
string | yes | Challenge returned by POST /user/login |
code |
string | yes | 6-digit code from the authenticator app, or recovery code (xxxxx-xxxxx) |
trust |
boolean | no | Trust this browser for 6 months (HttpOnly cookie) |
device |
object {name, token, platform?, model?, os_version?, app_version?} |
no | Mobile device to register, as for POST /user/login (see the device object) |
Response: the session, as for POST /user/login (exp, sub, iat, token), plus
recovery_codes (10 recovery codes, shown only once) when the authenticator app has just
been set up. 401 if the code is wrong (counted toward the lockout), 423 if the account
is locked, 403 if the challenge has expired.
GET /user/mfa, POST /user/mfa/setup, POST /user/mfa/enable, POST /user/mfa/recovery-codes, DELETE /user/mfa¶
Two-factor authentication of the logged-in user (My profile):
GET /user/mfa:available(migration applied),required(enforced by the administrator),enabled,pending,recovery_codes_left,trusted_browsers(id,label= User-Agent,created_at,expires_at,last_used_at);POST /user/mfa/setup: new secret to scan (secret,otpauth),409if already enabled;POST /user/mfa/enable(code): confirms the authenticator app, returnsrecovery_codes;POST /user/mfa/recovery-codes(code): new recovery codes;DELETE /user/mfa(authenticator or recoverycode, in the body): disables two-factor authentication and revokes the trusted browsers.
A wrong code answers 400 (and counts as a failed login).
DELETE /user/mfa/browser?id=¶
Revokes a trusted browser of the logged-in user (404 otherwise).
POST /user/mfa/reset — administrator¶
id: removes the authenticator app, the recovery codes and the trusted browsers of a
user who lost their phone; they will set up a new authenticator app at their
next login if two-factor authentication is enforced.
POST /user/unlock — administrator¶
id: unlocks an account locked after too many failed logins (404 if the user
does not exist).
GET /user/me¶
Profile of the logged-in user: id, username, firstname, lastname, email,
is_admin, language, theme (portal theme: system, light or dark),
alert_threshold (null if alerts are disabled).
GET /user¶
List of users for an administrator; the user themselves otherwise. Same fields
as GET /user/me, plus last_login (last successful login, null if never) and
locked_until (end of the lockout, null if the account is not locked).
POST /user — administrator¶
username, password, email (required), firstname, lastname, is_admin (optional;
user profile by default). 400 if the username or the email already exists.
PUT /user¶
| Parameter | Required | Description |
|---|---|---|
id |
yes | User to update (yourself, or anyone for an administrator) |
email, firstname, lastname |
no | New values |
password |
no | New password (ignored if empty) |
language |
no | fr, en, es, it, de or pt (European Portuguese); 400 for any other value |
theme |
no | Portal theme: system (follows the system's light/dark preference, default), light or dark; 400 for any other value |
alertThreshold |
no | Alert threshold in euros for new expenses; "" or 0 disables alerts |
is_admin |
no | Administrator (true) or user (false) profile; administrators only, 409 when removing the role from the last administrator |
curl -X PUT "https://exemple.fr/api/user?id=1" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"language":"en"}'
Returns the updated user.
GET /user/notifications¶
Channels of each notification type for the logged-in user:
{
"preferences": {
"sync": {"push": true, "email": false},
"rule": {"push": true, "email": false},
"expense": {"push": true, "email": true},
"budget": {"push": false, "email": false}
},
"smtp": true,
"address": "alice@example.com",
"email_available": true,
"push_available": true,
"budget_threshold": 100,
"alert_threshold": 200.0,
"budget_all": true,
"budget_thresholds": [{"category": 2, "threshold": 80}],
"budget_category": null,
"budgets": [{"id": 1, "name": "Alimentation"}, {"id": 2, "name": "Alimentation › Supermarché"}]
}
Types: sync (result of a synchronization), rule (new transaction matching a notify
rule), expense (expense above the alert threshold), budget (the month's spending in a
budgeted category above the budget threshold). Without a choice from the user, a type is
sent by push and not by email, except budget, disabled on every channel (to be enabled).
smtp tells whether an SMTP server is configured, address the profile's address (null
if none), email_available whether emails can be sent (both), push_available
whether an API key for the push notification service is configured (otherwise no
push notification is sent) and budget_threshold
the global budget alert threshold, as a percentage of the budget (100 by default), applied
to all the budgets when budget_all is true. budget_thresholds gives the thresholds of
the categories that have their own, used instead of the global one. alert_threshold
is the amount of a large expense (expense type, null: disabled, the same as in
GET /user/me), budget_category, for older clients, the only category of
budget_thresholds when the global threshold is off (null otherwise), and budgets the
expense categories that have a budget this month (a subcategory named "Category ›
Subcategory"), plus those of budget_thresholds that have none. Notifications (push and email) are
written in the profile's language (language, fr by default).
PUT /user/notifications¶
| Parameter | Required | Description |
|---|---|---|
preferences |
no | {type: {push?, email?}}, each channel true/false (or "true", "false", 1, 0) |
budgetThreshold |
no | Budget alert threshold (budget type) as a percentage of the budget: integer from 50 to 200 (number or string) |
alertThreshold |
no | Amount of a large expense (expense type) in euros; "" or 0 disables the alert |
budgetAll |
no | The global threshold (budgetThreshold) applies to all the budgets: true/false (or "true", "false", 1, 0) |
budgetThresholds |
no | Thresholds of categories, replacing the previous ones: [{category, threshold}], an expense category each (once), a threshold from 50 to 200; [] removes them all |
budgetCategory |
no | Older clients: the only budget watched (the global threshold off, this category at budgetThreshold or at the current threshold); 0 or "": all budgets, no threshold of a category. Not with budgetThresholds |
Missing types, channels and settings are kept; an invalid type, channel, value or setting
(including an unknown or income category): 400, nothing saved;
budgetCategory before the 2026-10-23_user_budget_alert_category migration, budgetAll or
budgetThresholds before the 2026-10-27_budget_alert_threshold migration: 409. Returns the result of GET /user/notifications.
curl -X PUT "https://exemple.fr/api/user/notifications" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"preferences":{"expense":{"email":true}}}'
# Budget alerts by email, from 90% of the budget
curl -X PUT "https://exemple.fr/api/user/notifications" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"preferences":{"budget":{"email":true}},"budgetThreshold":"90"}'
# 100% on all the budgets, 80% on categories 2 and 5
curl -X PUT "https://exemple.fr/api/user/notifications" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"budgetAll":true,"budgetThreshold":"100","budgetThresholds":[{"category":"2","threshold":"80"},{"category":"5","threshold":"80"}]}'
# Only categories 2 (80%) and 4 (100%), and expenses from 150 €
curl -X PUT "https://exemple.fr/api/user/notifications" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"budgetAll":false,"budgetThresholds":[{"category":"2","threshold":"80"},{"category":"4","threshold":"100"}],"alertThreshold":"150"}'
DELETE /user — administrator¶
id: you cannot delete yourself (400) or delete a user who owns
transactions (409).
Accounts and synchronization¶
Bank accounts belong to the household: every user sees every account and its
transactions. An account's user_id only indicates the user who
added it.
GET /bank¶
Bank accounts of the household, visible to every user (an account appears only
once); label is the name given to the account (null if it has none):
[{ "id": 1, "bankId": "DEMO0000001@bnp", "account_number": "DEMO0000001", "bank_name": "bnp", "label": "Compte joint" }]
GET /bank/accounts — administrator¶
Every account of the household with the user who added it and the result of its
last synchronization: id, bankId, account_number, bank_name, label, user_id and
username (who added it), last_sync_at, last_sync_status (OK, ERROR or null),
last_sync_message (number of new transactions or error).
POST /bank — administrator¶
Follows an account for the household. Parameters: account_number (account identifier in
woob), bank_name (woob backend name) and label (optional: name given to the account,
100 characters maximum); the account's user_id is the administrator who adds it. 409 if the household already follows this account, 400 if an identifier is invalid.
PUT /bank — administrator¶
Updates a followed account: id, account_number, bank_name and label (optional:
missing, the current name is kept; empty, it is removed). 404 if unknown, 409 if the
household already follows the target account.
DELETE /bank?id= — administrator¶
Stops following the account; its already imported transactions are kept.
GET /bank/backends, GET /bank/modules — administrator¶
Banks configured in woob (name, module, label: name given to the connection, null
if none) and banks supported by woob (module, description). A bank must be configured in woob (bank credentials) before
its accounts can be synchronized: from the portal (routes below).
GET /bank/module?module= — administrator¶
Parameters requested by a woob module: module, description, fields (key, label,
description, default, required, masked, choices). Nothing secret is returned.
A module not installed yet is installed first; 409 if the woob repository is not yet
approved (installing it would trust its key, see GET /bank/woob/update), as
for POST /bank/backend; 422 with the cause given by woob when it cannot load the
module (a missing Python package: "woob cannot load the bank module …").
POST /bank/backend — administrator¶
Configures a bank in woob: module, backend (name, [a-z0-9_-], no leading
-), settings (object of the module's parameters; only its keys are passed on,
values on a single line with no spaces, choices checked), label (optional: name given to
the connection, 100 characters max, for example "Joint account"). The credentials are
handed to woob, neither stored nor logged by Carbure (settings is masked in the
logs), and never passed on the woob command line. Returns
backend, label, the accounts found (same format as /bank/discover) and
two_factor. If the bank asks for a strong authentication on the first connection (woob
only starts it in interactive mode), the bank stays configured: accounts is empty,
two_factor is true, and its accounts are listed with GET /bank/discover/interactive.
409 if a bank with this name already exists in woob or if its module is not installed and
the woob repository not yet approved, 400 if a parameter is missing or if woob refuses.
PUT /bank/backend — administrator¶
Renames a bank connection: backend (name in woob), label (name given to the
connection; empty removes it). The name in woob, which identifies the accounts and the
transactions, does not change. Returns name and label. 404 if the bank is not
configured in woob, 400 if the name is invalid or too long.
DELETE /bank/backend — administrator¶
Deletes a bank connection: backend (name in woob, in the query string). woob
forgets it along with its credentials (woob config remove), its given name and its followed
accounts are deleted; already imported transactions are kept. Returns true. 404 if
the bank is not configured in woob, 400 if the name is invalid, 500 if woob did not
delete it.
GET /bank/woob — administrator¶
On a fresh installation (woob never run), every route that runs woob
answers 409 until the key of its repository is approved (GET /bank/woob/update):
on its first run, woob would trust that key on its own.
State of the woob bank module repository: repository (URL), trusted (key already
approved), fingerprint (SHA-1 fingerprint of the approved key, otherwise of the one woob
will ask to approve, to compare with the one published by the woob maintainers; null if
the repository is unreachable) and error (cause, null otherwise).
GET /bank/woob/update — administrator (SSE stream)¶
Updates the repositories and the bank modules (woob config update). Optional
parameter fingerprint (SHA-1 fingerprint confirmed by the administrator, 400 if it is
not 40 hexadecimal characters). As long as the repository key is not approved,
woob (which would trust it on its own) is only run if fingerprint is
the fingerprint of the key that Carbure downloads just before, and the key kept by woob
must be that one (otherwise it is deleted). Each woob line is sent
(data: <line>), then the result: Woob updated, Woob repository not trusted:
<fingerprint> (woob not run: compare it before trying again with it) or Woob update
failed: <cause> (including synchronization in progress: never during a
synchronization).
GET /bank/discover — administrator¶
Queries woob (woob bank list) and returns the accounts of the configured banks:
bankId, account_number, bank_name, label, balance, currency, followed. May
take several seconds (connection to the banks).
| Parameter | Description |
|---|---|
backend (optional) |
Only the accounts of this connection (woob backend, woob bank list -b <backend>); 400 if the name is invalid |
GET /bank/discover/interactive — administrator (SSE stream)¶
Accounts of one connection (backend, required, 400 if the name is invalid) when the
bank asks for a strong authentication: woob runs on a pseudo-terminal, starts the request
and waits for the approval in the bank's app (sometimes several minutes). Each message of
the bank is sent (data: Bank message <backend>: <message>), then the result: Accounts
<JSON list> (same format as /bank/discover) or Discovery failed: <cause> (including
synchronization in progress: never during a synchronization). The portal uses it after
POST /bank/backend with two_factor, or when GET /bank/discover fails on a strong
authentication request.
GET /bank/sync — SSE stream¶
Starts the synchronization of all accounts, or of a single one with
?account=<account_number>@<bank_name> (404 if it is not followed). The date and the result
are recorded for each account. Access is allowed with:
- a valid JWT (any user), or
- the
sync_tokenfrom the configuration, in theX-Sync-Tokenheader.
Deprecated ?token= parameter
The sync_token passed in the URL (?token=) is still accepted for scheduled tasks
already configured, but it is deprecated: the URL is written to the proxy logs, the
nginx logs and the shell history. The response then carries the headers
Deprecation: @1792108800 (deprecated since October 16, 2026, RFC 9745)
and Link: <https://carbure.org/api/#bank-sync>; rel="deprecation", and a warning
(without the token) is logged. Replace it with the X-Sync-Token header; if both
are present, the header wins.
Without a valid JWT or token (header or URL): 401. ?interactive=true (with account, for a logged-in user only,
400 otherwise) allows woob to trigger strong authentication: woob runs on a
pseudo-terminal (PHP's proc_open) and its messages are relayed as
data: Bank message <account>: <message> while it waits for approval in the
bank's app. Without this parameter, a bank that requires strong
authentication fails with …interactive mode to perform a two-factor authentication.
Upcoming transactions are optional: if their woob call fails (other than
strong authentication), the history is still synchronized, the failure is sent as
data: Coming failed <account>: <error> and the account stays OK, with the result
<n> new (coming failed: <error>).
For the scheduler (sync_token, never in interactive mode), an account whose bank
asks for approval in its app is synchronized again after woob_2fa_wait
seconds (15 by default), up to woob_2fa_retries times (1 by default): the approval may
have been given in the meantime. Only that account waits (data: Waiting for a validation <account>:
new attempt in 15 s (1/1), then : heartbeat comments); only the result of the last
attempt is recorded and notified.
The response is a text/event-stream: data: … messages (progress,
result of each step) and : heartbeat comments every 15 s while
woob runs. Only one synchronization can run at a time (otherwise:
data: Synchronization already in progress).
data: Syncing DEMO0000001@bnp (coming)...
data: Done DEMO0000001@bnp (coming): 3 received, 1 new
data: Syncing DEMO0000001@bnp (history)...
data: Done DEMO0000001@bnp (history): 100 received, 4 new
data: Notifying users of DEMO0000001@bnp...
data: Updating missing categories...
data: Checking the budgets...
data: Synchronization complete
After each account, every user of the household receives a notification (success
or failure, number of transactions to check), each new expense (a transaction
not seen before) whose amount reaches a user's alert_threshold sends them a
"Large expense to check" notification, and each new transaction
matching a notify rule is reported to the household. Once the transactions are categorized, the
budgets of the current month are checked (budget alert, also after an import). Each
notification is sent in its recipient's language, on the channels (push, email) they
chose (see GET /user/notifications).
Transactions¶
GET /transaction¶
| Parameter | Required | Description |
|---|---|---|
month, year |
no | Month of the transactions (current month by default) |
category |
no | Limits to the transactions of this category and its subcategories |
Transactions are sorted by actual date, newest first. Fields: id, uuid,
imported, date, rdate, type, label, category, amount, card, bank_name
and account_number (source account; null for transactions older than when these fields
were added), pointed, user.
curl "https://exemple.fr/api/transaction?month=9&year=2026&category=1" -H "Authorization: Bearer $TOKEN"
Types: 1 transfer, 2 direct debit, 3 check, 4 check deposit, 5 refund, 6 ATM withdrawal, 7 card statement, 8 expense, 9 fees, 12 pending card payment.
GET /transaction/periods¶
Months that have transactions, newest to oldest: [{"year": 2026, "month": 10}, …].
The portal builds its month and year lists from it.
GET /transaction/recurring¶
Recurring transactions of a month: found about once a month around the same day (salary, rent, subscriptions, bills). A series groups the transactions in the same direction (debit or credit) whose label, with digits removed (references, dates), is the same. It is recurring if it appears in at least 3 months of the period, at most 1.5 times per month, 3 times out of 4 within 5 days of the same day, and is still active (seen in the month or the previous month). A card payment must also keep the same amount within 20% (a subscription, not the restaurant); a direct debit or a transfer may vary.
| Parameter | Required | Description |
|---|---|---|
month, year |
no | Month (current month by default) |
months |
no | Number of previous months examined (2 to 24, default 6) |
Response, sorted by day: [{"label", "category", "amount" (last amount), "average",
"variable" (amount changing by more than 10%), "day" (usual day), "months" (number of
months it appears in), "last" (date of the last one), "ids" (its transactions in the month),
"date" (date of its first transaction in the month,nullif none), "status"}]. status is received, or expected if it is not in the month yet.
curl "https://exemple.fr/api/transaction/recurring?month=10&year=2026" -H "Authorization: Bearer $TOKEN"
GET /transaction/search¶
| Parameter | Required | Description |
|---|---|---|
query |
yes | Text searched in the label (2 characters minimum, case-insensitive) |
limit |
no | Maximum number of results (1 to 500, default 100) |
POST /transaction/import/preview¶
Reads a bank statement without saving anything.
| Parameter | Required | Description |
|---|---|---|
file |
yes | File content encoded in base64 (1 MB at most before encoding); never written to the logs |
filename |
no | File name (its extension helps detect the format) |
Formats: OFX/QFX (1.x SGML and 2.x XML), QIF, CAMT.053 (ISO 20022) and CSV: read with the
profile of its bank when its header is that of a known export (see
Features), otherwise separator detected
and columns guessed from the header (date or transaction date, label, amount or debit
and credit). The day/month order of dates is inferred from the file; the transaction type,
from the label (CB, PRLV, VIR, RETRAIT…) or, failing that, from the file.
Returns format (ofx, qif, camt, csv), bank (bank whose profile read a CSV:
id and name, for example {"id": "boursorama", "name": "Boursorama / BoursoBank"};
null for a CSV with guessed columns or another format), account (account number or IBAN read from
the file, otherwise null), from, to, counts (new, known, duplicate) and rows:
index, date, rdate, amount, label (cleaned as during synchronization), type,
status and, for a likely duplicate, match (id, date, label of the existing
transaction).
| Status | Meaning |
|---|---|
new |
New transaction |
known |
Same UUID as an existing transaction: it would be merged. Identical lines in the file are as many transactions (each with its own UUID) |
duplicate |
Same amount as an existing transaction within 3 days (date or actual date): probably the same one, synchronized with another label |
Errors: 400 (invalid base64, empty file), 413 (file too large, more than 5,000
transactions), 422 (unrecognized format, CSV columns not found, invalid XML).
POST /transaction/import¶
Same parameters, plus selected (optional): the index of the lines to import; by
default, the new lines. known lines are never imported. The transactions
belong to the logged-in user, then the categorization rules apply
whatever their date. Returns imported, categorized, from and to (period of the
chosen lines).
curl -X POST https://exemple.fr/api/transaction/import -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"file\": \"$(base64 -w0 releve.ofx)\", \"filename\": \"releve.ofx\"}"
PUT /transaction/category¶
id, category: assigns the category and checks off the transaction (autoPointed=false
to leave it unchecked).
PUT /transaction/pointed¶
id, pointed (boolean, default true): marks the transaction as checked or not.
curl -X PUT https://exemple.fr/api/transaction/pointed -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"id":42,"pointed":false}'
DELETE /transaction¶
id: deletes the transaction (404 if it does not exist). Its uuid is remembered
(bank_transaction_deleted table): the synchronization of its account and the import of a
statement ignore it, it does not come back (in an import preview, the line is known
with deleted: true).
Budgets and trends¶
GET /budget¶
month, year, category (parent category, 0 = root). For each category:
id, name, type, icon, color, budget, consummed (absolute sum of the
transactions of the category and its subcategories), progress (%), children
(number of subcategories), children_budget (sum of their budgets) and budget_mode:
own (amount set for the category) or children (category with subcategories and
no amount set: budget is then the sum of the subcategories' budgets).
GET /budget/flow¶
month, year (current month by default): money flow of the month, for the
"Money flow of the month" diagram. Amounts are grouped by top-level category; the
HORS-BUDGET categories (internal transfers) are excluded, except the savings category
(savings_category); uncategorized transactions are kept.
| Field | Description |
|---|---|
month |
YYYY-MM |
income, expenses |
Income and expenses by top-level category: id, name, color, icon, amount (positive), from largest to smallest |
savings |
Net amount put into the savings category and its subcategories |
totalIncome, totalExpenses |
Total income and total expenses |
balance |
Remainder: income − expenses − savings (if positive); negative in case of a deficit |
GET /budget/flow/transactions¶
Transactions of a node of the flow diagram, newest to oldest, with
the same classification as GET /budget/flow.
| Parameter | Required | Description |
|---|---|---|
kind |
yes | income, expense or savings |
categories |
no | Comma-separated top-level category identifiers (0 = uncategorized); all by default. Ignored for savings |
month, year |
no | Current month by default |
Returns the columns of bank_transaction and top (top-level category).
POST /budget¶
category, amount (required), month, year (current month by default): creates or
updates the category's budget for the month. For a category with subcategories, the
amount replaces the sum of their budgets; 0 reverts to that sum.
POST /budget/copy¶
month, year (target month, current month by default), overwrite (false by default):
copies the previous month's budgets to the target month. Without overwrite, only the
categories with no budget in the target month are copied; with overwrite, all the
previous month's budgets replace those of the target month. Returns month, source (previous
month, YYYY-MM), copied (budgets written) and overwrite.
GET /budget/trends¶
| Parameter | Required | Description |
|---|---|---|
months |
no | Number of months (1 to 60, default 24) |
offset |
no | Number of months between the current month and the last month of the period (default 0). Used for comparisons: offset=months for the previous period, offset=12 for the same period last year |
Returns one item per month (oldest to newest, empty months included):
| Field | Description |
|---|---|
month |
YYYY-MM |
debit |
Expenses of the month, off-budget categories excluded (positive amount) |
credit |
Income of the month, off-budget categories excluded |
offBudget |
Balance of the HORS-BUDGET categories (e.g. transfers to savings) |
planned |
Sum of the budgets of the top-level categories (amount set, or sum of the subcategories) |
savings |
Net amount put into the savings category (savings_category, Epargne by default) and its subcategories, positive when saving |
[{ "month": "2025-05", "debit": 2130.37, "credit": 5114, "offBudget": -500, "planned": 2330, "savings": 2983.63 }]
Insights¶
GET /budget/insights¶
month, year: value of each insight for the month (id, name, color, icon,
amount). A failing query gives 0 without blocking the others.
GET /budget/insights/history¶
year: for each insight, the history list of amounts month by month.
GET /insight, POST /insight, PUT /insight, DELETE /insight?id= — administrator¶
Management of insights, with their SQL query:
| Parameter | Required | Description |
|---|---|---|
id |
PUT, DELETE |
Insight to update or delete (404 if unknown) |
name |
yes | 1 to 20 characters |
color |
yes | red, orange, amber, lime, green, teal, cyan, blue, indigo, purple, pink, brown or gray |
sql |
yes | SELECT … AS amount query (500 characters at most); {month} and {year} are replaced by the requested month |
icon |
no | Material icon ([a-z0-9_]); chosen from the name if empty |
The query must be a single SELECT returning an amount column, with no ;,
comment or \, no write, administration, file or
delay keyword, and it only reads the budget tables (bank_transaction,
bank_transaction_category, bank_transaction_category_keyword, budget,
bank_account, bank_backend, without a database name). It is tested on the current month before
being saved (400 with the cause otherwise) and run in a read-only
transaction, in 5 seconds at most (beyond that: 400 when saving, 0 in the lists). POST and PUT return the insight with
the amount for the current month.
POST /insight/check — administrator¶
sql, month, year (current month by default): checks a query without saving it
(same checks), while typing. Returns {"valid": true, "amount": …} or
{"valid": false, "error": "…"} (always 200).
Categories and rules¶
GET /category¶
All categories: id, name, parent_category, type (DEBIT, CREDIT,
HORS-BUDGET), icon, color.
POST /category, PUT /category, DELETE /category?id= — administrator¶
Creates or updates a category (name, type: DEBIT, CREDIT or HORS-BUDGET,
parent_category, icon, color; id for an update) or deletes it. 409 if the
name already exists or if the category to delete has subcategories, 400 for the default
category (0) or a parent that is not a main category. On deletion, the
transactions move to category 0 and the category's budgets and rules are deleted.
GET /category/keyword¶
Automatic categorization rules: id, keyword, category, category_name.
POST /category/keyword — administrator¶
keyword (1 to 60 characters, saved in uppercase), category: adds a rule.
Errors: 400 empty keyword, 404 unknown category, 409 keyword already exists.
curl -X POST https://exemple.fr/api/category/keyword -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"keyword":"decathlon","category":9}'
PUT /category/keyword — administrator¶
id, keyword, category, notify: updates a rule (409 if the keyword already exists).
POST /category/keyword also accepts notify: each new synchronized transaction
that contains the keyword then sends a notification to every user of the household.
GET /category/keyword/count?id= — administrator¶
Number of transactions whose label contains the keyword (matching), including those already
in the rule's category (categorized).
DELETE /category/keyword — administrator¶
id: deletes a rule (404 if unknown).
POST /category/keyword/apply — administrator¶
Applies every rule to every transaction in the database, including those already
categorized (the first rule found in the label wins). Returns
{ "updated": <number of transactions whose category changed> }. Creating or updating a
rule (POST/PUT /category/keyword) also applies it immediately to the whole history and
returns applied, the number of recategorized transactions. With the
keep_categorized setting (below), these three routes only touch uncategorized
transactions.
GET /category/keyword/settings¶
{ "keep_categorized": false }: true if the rules leave the already categorized
transactions of the history alone (off by default).
PUT /category/keyword/settings — administrator¶
keep_categorized (true/false): turns the setting on or off, returns the new state.
When on, resynchronizing and creating or updating a rule only categorize the transactions
without a category; categories chosen by hand or imported are never overwritten.
Devices¶
GET /device¶
Devices of the logged-in user: id, user_id, name, token, platform (ios or
android; ios until the 2026-10-23_device_platform migration is applied),
lastLogin, model, os_version, app_version (null if the app did not send them
or until the 2026-10-25_device_model migration is applied). token is null when the
app logged in without a notification token (notifications not available in the app,
refused, or token not received yet): the device is listed, once per name and platform, but
no notification reaches it; it is replaced by the device with its token as soon as the app
sends one (migration 2026-10-28_device_without_token; before it, it is not listed). Each
login without a token is logged.
POST /device¶
Registers the push token of the signed-in mobile app, received after its login or changed
since (FCM): token (required, 400 otherwise), name, platform (ios or android;
empty: the one of the token, 400 if unknown), model, os_version, app_version, like
the device object of the login. The device is the one of the signed-in user; it replaces
the device listed without token of the same name and platform. Returns the registered device
(id, user_id, name, token, platform, model, os_version, app_version).
GET /device/link¶
Address of the API given to the mobile apps by the QR code of My devices:
{ "server": "https://carbure.example.com/api/" }, that is public_url followed by
/api/, or null without this setting (the portal then uses its own address).
DELETE /device¶
id: deletes a device of the logged-in user (it will no longer receive
notifications). 404 if the device does not exist or belongs to another user.
GET /user/browser, DELETE /user/browser?id=¶
Web browsers from which the authenticated user logged in to the portal (a login without
a device object), the last used first: id, label (User-Agent), created_at (first
login), last_login, current (true for the browser of the request). An HttpOnly
cookie carbure_browser (sent only to the /user routes) recognizes the browser; the
database keeps only its SHA-256 hash; a browser unused for 6 months is forgotten. It is
not a device: it receives no notification and does not exempt from two-factor
authentication. DELETE forgets it (404 if it does not exist or belongs to another
user) without closing its session: it shows up again at its next login. Empty list as
long as the migration 2026-10-26_user_browser is not applied.
POST /device/push¶
Sends a test notification to every device of the logged-in user, through the
Carbure Push service. Returns the result for each token: success, http_code,
status (sent, unregistered, invalid_token, forbidden, device_limit, error),
response and, if it was not sent, error (without an API key: Push service not
configured (push_api_key)). An unregistered device (app deleted, token
expired) is deleted.
AI agents (MCP)¶
GET /token, POST /token, DELETE /token?id=¶
Access tokens of the logged-in user, for the MCP server. POST (name: 1 to 50
characters; days: 30, 90, 365, or 0/empty for no expiration) returns the token
(token, cbt_ prefix) only once, with id, name, token_hint and
expires_at; 409 beyond 20 tokens. The list only gives id, name,
token_hint, created_at, last_used_at, expires_at (null = no expiration) and
expired. DELETE revokes a token (404 if it does not exist or belongs to another
user).
GET /mcp/settings, PUT /mcp/settings¶
{"enabled": true|false}: state of the MCP server (disabled by default). PUT (enabled)
enables or disables it — administrator.
POST /mcp — public (access token)¶
Read-only MCP server (JSON-RPC 2.0, Streamable HTTP transport), authenticated with
Authorization: Bearer <token> (or ?token=). GET /mcp answers 405. See
AI agents (MCP).
OAuth 2.1 for AI agents — public (unless stated otherwise)¶
For agents that only take the URL of the MCP server (Claude web, Desktop, mobile).
Responses and errors in the OAuth format ({"error", "error_description"}), not in the API
format.
| Route | Purpose |
|---|---|
GET /.well-known/oauth-protected-resource (and …/api/mcp) |
MCP server metadata (RFC 9728): resource, authorization_servers |
GET /.well-known/oauth-authorization-server |
OAuth metadata (RFC 8414): endpoints, S256, client authentication methods |
POST /api/oauth/register |
Dynamic registration (RFC 7591): redirect_uris (HTTPS, or HTTP on localhost), client_name, token_endpoint_auth_method (none, client_secret_post, client_secret_basic); 201 with client_id (and client_secret) |
GET /api/oauth/authorize |
response_type=code, client_id, redirect_uri, state, code_challenge (S256): 302 to the portal's consent page, or to the agent with error=; 400 if the client or the redirect URI is unknown |
GET /api/oauth/client?request= — JWT |
Agent name and redirect host, for the consent page |
POST /api/oauth/approve — JWT |
request, approved: {"redirect"} to the agent, with the code (5 minutes, single use) or error=access_denied |
POST /api/oauth/token |
Form or JSON: grant_type=authorization_code (code, redirect_uri, client_id, code_verifier) or refresh_token; {"access_token", "token_type": "Bearer", "expires_in", "refresh_token"} |
They all answer 403 (access_denied) when the MCP server is disabled.
Backup¶
The portal builds the ZIP of an export and reads the ZIP of an import: the API only exchanges
JSON. Datasets (in dependency order): categories, rules, budgets,
insights, transactions, deleted_transactions. The owner of a transaction
(user) and the author of a deletion (user_id) are written as their
username. Never exported: users, passwords, tokens, OAuth, devices,
settings, bank connections and accounts, budget alerts already sent.
GET /backup — administrator¶
The datasets that can be backed up and their row count: [{"name":"categories","count":12}, …].
GET /backup/export — administrator¶
Optional parameter datasets: comma-separated names (all by default; 400
for an unknown name). Response: format (carbure-backup), version (1), created_at,
carbure (version), schema (latest migration), counts and datasets (the rows of
each dataset, with the columns of its table).
POST /backup/import — administrator¶
Body: {"datasets": {"rules": [{"id":1,"keyword":"EDF","category":3,"notify":false}], …}}.
Deletes and replaces the rows of each dataset provided (an empty list empties it); the
others are kept. Every row is checked before anything is deleted (types,
lengths, dates, unique identifiers, hierarchy, category icon and color,
query, color and icon of insights as when they are saved), then the import runs in a single transaction: 400 with the
details (dataset, row, column) and nothing is changed if a row is invalid, if a
database constraint is violated, or if a kept or imported dataset refers to a
missing category. Category 0 (default) is recreated if it is missing; the budget
alerts of categories that no longer exist are deleted. An unknown username becomes
the administrator running the import; a deletion without an author (user_id set to null) stays that way.
The portal rejects a file whose format version is newer than its own. Response: {"imported": {"rules": 1}}. Body limited to 32 MB
by nginx (2 MB for the other routes).
System¶
GET /health — public¶
State of the instance for the Docker HEALTHCHECK and monitoring tools, with no
authentication and no data: {"status":"ok","version":"1.2.0","database":"ok","schema":"<schema version>","min_mobile_version":"1.0","currency":"EUR"},
500 if the database does not respond, {"status":"setup","version":"…","min_mobile_version":"1.0"} as long as Carbure is
not installed. version is the Carbure version (VERSION file, dev outside a release).
currency is the currency of the amounts (setting currency, ISO 4217 code, EUR by default).
min_mobile_version is the oldest version of the iOS app (its MARKETING_VERSION)
that the server supports: an older app asks its user to update
it. It is set in the code (Carbure\Lib\MobileApp::MIN_VERSION) and is only raised
when an API change can no longer be read by older versions of the app,
once the new version is published on the App Store.
On a demo instance (demo setting, ltoinel/carbure:demo image), the response
also contains "demo":true: the portal then shows the public account (demo / carbure)
on the login screen. The field is absent on a regular instance.
GET /system/logs — administrator¶
Log files of the instance, newest first: [{name, size, modified}].
GET /system/logs/retention — administrator¶
Retention period of the log files: {"days": 90} (0: kept indefinitely).
Older files are deleted at the end of the bank synchronization
(log_retention_days setting).
GET /system/logs/entries — administrator¶
Entries of a file, newest first. Parameters: file (name returned by
/system/logs), optional level (minimum level: DEBUG, INFO, WARN, ERROR),
optional search (text or request identifier), limit (1 to 1000, 200 by default).
Response: {file, entries: [{time, level, uid, caller, message, ip, user, method, path}], truncated}
(ip, user, method and path: entries in JSON format, absent from older
files; the context of an entry follows its message); only the
last 2 MB are read (truncated) and tokens are masked. 400 for an invalid file
name, 404 if it does not exist.
GET /system/schema — administrator¶
Database schema version and migrations to apply:
{"version": "2026-10-13_base", "pending": []}.
GET /system/release — administrator¶
Latest published release of Carbure, asked to the API of GitHub at most once a day
(update_check setting): {"enabled": true, "current": "1.9.1", "latest": "1.10.0",
"url": "https://github.com/ltoinel/Carbure/releases/tag/v1.10.0", "available": true,
"checked_at": "2026-11-01 10:00:00"}. available: latest is newer than current (never
for a development version); latest, url and checked_at are null until known.
POST /system/migrate — administrator¶
Applies the pending migrations (Update button in the portal):
{"version": "…", "applied": ["…"]}; 500 with the cause if a migration fails (the
following ones are not run).
POST /system/jwt-secret — administrator¶
Replaces the jwtsecret of the configuration with a random value: every session
(portal and iOS app) ends. GET /system/schema reports weakJwtSecret
when the secret is the sample one (or too short): the portal then offers to
renew it.
GET /system/config — administrator¶
The configuration file, by section: file (path in the instance), writable, and
sections (name, settings). Each setting: key, value (masked for secrets;
true/false for a toggle; a list for regex_label), set, secret,
editable, kind (select, bool, number, text, url, email, password, list) and, depending on the type,
options, min, max. Editable settings missing from the file are listed with
set: false.
PUT /system/config — administrator¶
values: the new values by key, only among the editable settings
(log_level, log_retention_days, savings_category, public_url, currency, next_month_day, woob_transactions, woob_logging,
woob_debug, woob_auto_update, woob_2fa_wait, woob_2fa_retries, push_api_key, smtp_host, smtp_port, smtp_encryption, smtp_auth,
smtp_username, smtp_password, smtp_from, smtp_from_name). All values are checked before anything
is written (400 otherwise, file unchanged); the old file is kept as .bak. Returns the
configuration as GET does.
GET /system/sync — administrator¶
reveal (optional, false by default): url of /api/bank/sync, token (the
sync_token, masked unless reveal=true; null if there is none), masked and
accounts (the followed accounts, for ?account=) and header (X-Sync-Token, the header
that carries the token). The request to schedule is
curl -sN -H "<header>: <token>" "<url>": the token is never in the URL.
POST /system/smtp-test — administrator¶
to (optional, the administrator's address by default): sends a test email with
the SMTP server of the configuration, in the language of the user with that address (otherwise
the administrator's). Returns {"sent": true, "to": "…"}; 400 with no
SMTP server configured or no recipient, 502 if the server is unreachable or rejects
the email (its response is in the error message).
GET /system/push — administrator¶
The instance's push notification service, its API key and the administrator's
consent:
{"url": "https://api.carbure.org", "official": true, "registered": true, "api_key": "cpk_…", "consent": {"at": "2026-10-17T08:00:00Z", "url": "https://api.carbure.org"}, "consent_valid": true, "config_writable": true}.
url is the relay used (always https://api.carbure.org), official whether it is
the official service (always true), registered whether an API key is configured,
api_key the key ("" when not registered; restricted to administrators, never in
GET /system/config), consent the last recorded consent (null if there is
none), consent_valid whether it was given for this relay and config_writable whether the
configuration file can receive the key.
GET /system/push-status — administrator¶
The state of the push notification service, checked with the relay on each call:
{"relay": {"reachable": true, "status": "ok", "version": "1.0.0", "error": null}, "key": {"state": "valid", "key_id": "0123456789abcdef", "devices": 3, "used_today": 12, "limits": {"notifications_per_day": 500, "devices": 20}}}.
relay: response ofGET /v1/health;statusisok,error(the relay responds but not "ok":errorgives the reason) orunreachable(reachableset tofalse).key: response ofGET /v1/keys/currentwith the instance's key;stateisvalid(withkey_id,devices,used_today,limits),invalid(rejected: revoked or unknown),none(no key) orunknown(relay unreachable or unexpected response), witherrorfor these last two cases andinvalid.
POST /system/push-register — administrator¶
Registers the instance with the push notification service (https://api.carbure.org): solves its proof of work, obtains an API key and writes it to
push_api_key. With a key already configured, renews it (devices keep
receiving notifications). The key is not returned here (GET /system/push gives it):
{"registered": true, "renewed": false, "key_id": "…", "limits": {…}}. 502 if the service
is unreachable or refuses, 429 beyond its registration limits; the configuration
file must be writable (checked before the call).
Parameter consent (true): the administrator agrees that notifications (device
tokens, title and text) go through this relay. Required for a registration,
and for a renewal if the recorded consent applies to another relay (the one
of an older version): otherwise 400. It is recorded (date and relay address, settings
table) and returned by GET /system/push.
POST /system/push-test — administrator¶
Sends a test notification to the administrator's devices, in their language:
{"sent": 1, "devices": 2, "results": [{"device": "iPhone", "sent": true, "status": "sent", "error": null}, …]}
(without the tokens). A device without a notification token has the status no_token.
400 with no API key or no device.
POST /system/sync-token — administrator¶
Writes a new random sync_token to the configuration and returns it: {"token": "…"}.
Scheduled tasks must use the new one.
Setup wizard¶
As long as data/conf/prod.ini does not exist, the API only answers these routes (all others
return 503 with "setup": true):
| Route | Purpose |
|---|---|
GET /setup |
State of the wizard: codeRequired, dbPasswordFromEnvironment and default values (db_host, db_port, db_name, db_user, admin_user, language) taken from the Docker environment |
POST /setup/database |
Tests the connection (db_host, db_port, db_name, db_user, db_password) and describes the installation: state (none = empty database, current = up to date, outdated = migrations to apply), version, pending, hasAdmin |
POST /setup/install |
Creates or migrates the schema, creates the first administrator if there is none (admin_user, admin_password of at least 8 characters, admin_email, language) and writes the configuration; 409 without backup_confirmed when migrations need to be applied. On an empty database, starter_categories (optional) adds the categories and rules from sql/starter.json, and starter_insights its insights |
GET /health |
{"status":"setup","version":"…","min_mobile_version":"…"} |
From the Internet, or if data/conf/setup.code exists, POST requests require the
code parameter (setup code), otherwise 403. See Security.
OpenAPI specification¶
swagger/swagger.php generates an OpenAPI 3 specification by reflection on the
#[ApiRoute] attributes; swagger/index.html displays it with Swagger UI, also in the
portal (Administration → API), authenticated with the administrator's session. The
specification paths start with /api (server /): Try it out calls
/api/... on the server that serves Swagger UI. swagger/carbure.css darkens the colors of
Swagger UI's light theme (WCAG AA contrast); its dark theme follows the portal's, or
the system preference when Swagger UI is opened on its own.