API Design Conventions
This document defines the conventions for designing and implementing REST API endpoints in the Patrimoine backend.
URL Versioning
Section titled “URL Versioning”| Route prefix | Purpose | Versioned |
|---|---|---|
/api/v1/ |
All resource routes (accounts, snapshots, charts, etc.) | Yes |
/api/v1/auth/ |
Authenticated account self-service (profile, password, TOTP, email change, token revoke) | Yes |
/auth/ |
Unauthenticated session- and credential-establishing flows, and externally registered or email-linked URLs (OIDC start/callback/logout, config, login, register, invites, password reset) | No (public) |
/health |
Health check endpoint | No (public) |
Resource routes are always versioned. Auth routes split by whether they establish a session/credential or are externally registered — see “Auth route groups” below. Health routes are intentionally unversioned.
Auth route groups
Section titled “Auth route groups”app/routers/auth.py declares two routers rather than one, each mounted with its prefix at include time like every other router (see “Router Pattern” below):
public_router(mounted at/auth): unauthenticated flows that establish or end a session or credential, or whose URLs are registered externally or linked from email — OIDC start/callback/logout,config,register, invite validate/redeem, login, token login, mobile exchange, forgot/reset password.account_router(mounted at/api/v1/auth): versioned account self-service — profile (me), password change, TOTP setup/verify/disable, email change, and API token revoke.
Router Pattern
Section titled “Router Pattern”Routers are defined without a prefix at construction time. The prefix is applied when the router is included in the application:
# In the router module (e.g., app/routers/accounts.py)router = APIRouter()
@router.get("/")async def list_accounts(...): ...
# In app/main.pyapp.include_router(router, prefix="/api/v1/accounts", tags=["accounts"])This keeps route definitions portable and centralizes prefix management in main.py.
Role-Gated Endpoints
Section titled “Role-Gated Endpoints”Most endpoints are ownership-scoped: Depends(get_current_user) plus filtering by user.id. A small number of endpoints (currently only /api/v1/admin/*) are role-gated instead: they use Depends(get_current_admin_user), which wraps get_current_user and raises APIError(code="admin_required", status_code=403) for non-admins.
Role-gated endpoints must still respect ownership boundaries where relevant — the admin role in this app is scoped to user account management only (enable/disable, promote/demote, invite). It does not grant visibility into other users’ financial data (accounts, snapshots, transactions, etc.); no admin endpoint may query those tables across users.
Schema-per-Operation
Section titled “Schema-per-Operation”Pydantic schemas are split into Create, Update, and Out variants per model. This prevents mass-assignment vulnerabilities by ensuring each endpoint only accepts the fields it needs.
| Schema | Purpose | Used by |
|---|---|---|
AccountCreate |
Fields allowed on creation | POST /api/v1/accounts |
AccountUpdate |
Fields allowed on update (all optional) | PATCH /api/v1/accounts/{id} |
AccountOut |
Fields returned to the client | All response bodies |
Never reuse a single schema for both input and output. The Out schema must never expose internal fields like user_id, hashed_password, etc.
Error Format
Section titled “Error Format”All error responses follow a standardized structure:
{ "error": { "code": "not_found", "message": "Account not found", "detail": "No account with id 42 belongs to the current user" }}The detail field is optional and provides additional context when available.
Status Code to Error Code Map
Section titled “Status Code to Error Code Map”The table below is only the fallback used by app/errors.py’s http_exception_handler when a route raises a bare HTTPException — which is rare.
In practice, almost every real endpoint error is raised as APIError with a resource-specific semantic code (account_not_found, category_not_found, tag_not_found, webhook_not_found, planned_transaction_not_found, budget_not_found, holding_not_found, rule_not_found, api_token_invalid, plus many *_conflict/*_mismatch codes, etc.), not one of the generic codes below.
See the API Reference’s per-endpoint error-code documentation for the actual codes an endpoint returns.
| HTTP Status | Error Code (bare HTTPException fallback only) |
|---|---|
| 400 | bad_request |
| 401 | unauthorized |
| 403 | forbidden |
| 404 | not_found |
| 405 | method_not_allowed |
| 409 | conflict |
| 422 | validation_error |
| 429 | rate_limited |
| 500 | internal_error |
Authentication failures raised by get_current_user are APIError responses with distinct codes and unchanged statuses: not_authenticated (401, no usable credentials), token_expired (401, the session passed its absolute lifetime), token_revoked (401), invalid_api_token (401), session_invalid (401, the session was ended by a credential change or its user no longer exists) and account_disabled (403). Clients should decide on the HTTP status, with one exception: a 401 is also raised for a wrong password or TOTP code (invalid_credentials, invalid_password, totp_invalid, totp_incorrect_password, totp_required), where the credential is fine and the session must not end. The Android app therefore ends its session only on the codes it lists in ApiErrorCodes.kt as “credential rejected” (or on a 401 with no readable code), and backend/tests/test_android_error_contract.py fails when the backend raises a 401 code that list does not classify.
New endpoints should raise APIError with a specific code, not rely on this fallback map.
Any exception no handler takes becomes a 500 with code internal_error and the generic message Internal server error; nothing about the cause reaches the client. The traceback is logged server-side with the request id, which is also returned in the X-Request-ID response header.
Authentication & Authorization
Section titled “Authentication & Authorization”All protected endpoints use FastAPI dependency injection:
from app.database import get_dbfrom app.dependencies import get_current_user
@router.get("/")async def list_accounts( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db),): ...Depends(get_current_user)— Validates the JWT/session token and returns the authenticatedUser. Raises 401 if unauthenticated.Depends(get_db)— Provides an async database session.
No endpoint should accept a user_id from the request body or query parameters. The user identity is always derived from the authentication token.
CSRF Protection & Bearer Tokens
Section titled “CSRF Protection & Bearer Tokens”State-changing requests (POST/PATCH/PUT/DELETE) must carry an X-Requested-With header, enforced by app/middleware/csrf.py’s CSRFMiddleware. A request missing it on a cookie-authenticated session is rejected with 403 forbidden. This is exempt for:
- Safe methods (
GET,HEAD,OPTIONS) /health- E2E test mode (
settings.e2e_test_mode) - Any request carrying
Authorization: Bearer ptr_...
API tokens (the ptr_-prefixed Bearer tokens issued under /api/v1/tokens) bypass CSRF entirely — they’re not cookie-based, so there’s no CSRF risk.
A new endpoint doesn’t need to do anything special to get this: the middleware applies globally.
Pagination
Section titled “Pagination”List endpoints accept limit and offset query parameters and return the total count via the X-Total-Count response header.
GET /api/v1/accounts?limit=20&offset=0
Response Headers: X-Total-Count: 47limit— Maximum number of items to return (default and max enforced server-side)offset— Number of items to skip (zero-based)X-Total-Count— Total number of items matching the query (before pagination)
Totals that add balances across currencies convert a currency that has no exchange rate at
parity and name those currencies in an X-Missing-Rates response header (for example JPY,USD),
so the figures are never silently wrong. Use rate_or_par() from app/utils/fx_flags.py instead
of rates.get(currency, Decimal("1")). The web app hides its missing-rates notice while every
currency the headers named is in the user’s missing_rates_dismissed profile field; dismissing the
notice adds those currencies to it.
Standard shape: limit defaults to 50 with a maximum of 250; offset defaults to 0.
Standard-shape endpoints take page: Pagination = Depends(pagination_params) from
app/dependencies.py instead of repeating the two Query(...) declarations.
Read page.limit/page.offset where a router previously read separate limit/offset
parameters — the service call and the X-Total-Count header are unchanged.
Documented exceptions
Section titled “Documented exceptions”A new list endpoint uses the standard shape above unless an existing client dependency is documented in this table.
| Endpoint | Default | Maximum | Reason |
|---|---|---|---|
GET /api/v1/exchange-rates |
50 | 2000 | The web app’s per-pair history views (useExchangeRateHistoryQuery) fetch a pair’s full rate history in one request via limit=2000. |
GET /api/v1/subcategories |
250 | 250 | The web and Android clients fetch the whole subcategory list with no explicit limit; a lower default would silently truncate it. |
GET /api/v1/snapshots (limit) |
250 | 500 | Row-count pagination for large per-account snapshot histories; a caller needing everything pages through offset via X-Total-Count rather than relying on an unbounded default. |
GET /api/v1/snapshots (date_limit) |
none (opt-in) | 100 | Alternate date-pivot pagination mode used by the web UI’s cards view — pages by distinct date so a page is never split across a date boundary. |
GET /api/v1/institutions/grid (limit) |
25 | 250 | The institution-grouped, all-history pivot grid paginates by distinct date. |
GET /api/v1/budget/budgets |
250 | 250 | The web app (useBudget.ts) and Android (PatrimoineApi.kt) both fetch the whole budgets list with no explicit limit; a lower default would silently truncate it. |
GET /api/v1/accounts/deleted/list |
250 | 250 | The web app’s createCrudHooks.ts deleted-list query and Android’s PatrimoineApi.kt both fetch the whole trash list with no explicit limit. |
GET /api/v1/institutions/deleted/list |
250 | 250 | Same shipped-client dependency as the accounts trash list above. |
GET /api/v1/categorization-rules/deleted/list |
250 | 250 | Same shipped-client dependency as the accounts trash list above. |
GET /api/v1/planned-transactions/deleted/list |
250 | 250 | Same shipped-client dependency as the accounts trash list above. |
GET /api/v1/snapshots/grid (limit) |
none (opt-in) | 250 | The pivoted date-vs-account grid paginates by distinct date, like the institutions grid above; omitting limit returns every matching date unbounded, matching prior behavior for callers that page entirely via date_from/date_to. |
GET /api/v1/budget/transactions/ids |
none | 50000 | Backs the web app’s “select all N matching” bulk-select flow, which needs every matching id at once. A match above 50000 is refused with 422 too_many_results (detail.max_results) instead of being truncated; the caller narrows its filters. |
GET /api/v1/budget/transactions/converted |
none | 50000 | The web app’s spending charts (useTransactionsConvertedQuery) load the full filtered set, since a capped page silently undercounted “ALL” ranges. Same 422 too_many_results refusal above 50000 rows. |
GET /api/v1/snapshots/latest-per-account |
none | one row per account | Prefill for the snapshot entry form: returns the latest snapshot of each account, so its size is bounded by the number of accounts and an offset window would drop accounts from the form. |
GET /api/v1/market-data/search |
none | upstream | Ticker search results come from the quote provider, which bounds their number; there is no stored row set to page through. |
Series endpoints
Section titled “Series endpoints”Chart endpoints under /api/v1/charts/* return aggregated time series (net-worth
points per snapshot date, one trend series per account, stacked series per account
type, statistics/heatmap/waterfall objects) bounded by the caller’s own from/to
date-range parameters, not by an entity-row count. POST /api/v1/charts/balance-forecast/what-if takes the hypothetical items in its body and is read-only. GET /api/v1/charts/balance-forecast is the
forward-looking exception, with no from/to: it projects from today and is bounded by its days
parameter (1 to 365, so at most 366 daily points), returned as one object and likewise without
limit/offset.
They are intentionally not
given limit/offset: an offset window over a series would skip or truncate
points and render an incorrect chart, and both shipped clients call these
endpoints without paging parameters today.
Export Formats
Section titled “Export Formats”Two different conventions select an export’s file format, and both are intentional:
- Path-per-format, for downloadable files of user data where each format is a
distinct file type, filename and media type:
GET /api/v1/export/csv|ods|xlsx|backupandGET /api/v1/export/holdings/csv|ods|xlsx(app/routers/export.py), andGET /api/v1/budget/transactions/export/{fmt}(the transactions export). ?format=, for alternative serializations (CSV/JSON) of one filtered list query:GET /api/v1/audit-logs/export?format=(app/routers/audit_logs.py) and the admin audit export inapp/routers/admin_users.py. Both are capped at 50,000 rows (newest first); a capped response carriesX-Truncated: true.
Rule for new endpoints: if the export is a full file download of a resource (a
distinct filename and media type per format, no other query filters that change the
underlying result set), use path-per-format. If the export is a serialization choice
over an otherwise-filtered list query (the same rows, just CSV vs JSON), use ?format=.
OpenAPI Schema
Section titled “OpenAPI Schema”The OpenAPI specification is committed as backend/openapi.json. This ensures the API contract is version-controlled and reviewable.
CI Check
Section titled “CI Check”CI verifies that the committed schema matches the code:
cd backend && uv run python scripts/export_openapi.py --checkIf the check fails, the schema is out of date. Regenerate it:
cd backend && uv run python scripts/export_openapi.pyAlways regenerate and commit openapi.json after making API changes (new endpoints, changed schemas, new parameters, etc.).
User Isolation
Section titled “User Isolation”All database queries must filter by user_id derived from get_current_user. A user must never be able to access another user’s data.
stmt = select(Account).where(Account.user_id == user.id)Never trust a user_id or owner_id from the client. Always use the authenticated user’s ID.
Soft-Delete and Trash Pattern
Section titled “Soft-Delete and Trash Pattern”Entities that support soft-delete (Account, Snapshot, Institution, Transaction) use a deleted_at: datetime | None column. When set, the record is excluded from normal list queries but preserved for potential restoration.
Endpoint Convention
Section titled “Endpoint Convention”All soft-deletable entities follow the same URL pattern:
| Action | Method | URL Pattern | Description |
|---|---|---|---|
| Soft-delete | DELETE |
/{entity}/{id} |
Sets deleted_at; cascades to child entities |
| Restore | POST |
/{entity}/{id}/restore |
Clears deleted_at; cascades to child entities |
| Permanent delete | DELETE |
/{entity}/{id}/permanent |
Hard-deletes the row (only works on already soft-deleted records); CASCADE FK removes children |
| List deleted | GET |
/{entity}/deleted/list |
Returns only soft-deleted records |
| Empty trash | DELETE |
/{entity}/deleted/empty |
Permanently deletes all soft-deleted records for the user |
Transaction follows the same convention under /api/v1/budget: GET /transactions/deleted/list is paginated (limit/offset, X-Total-Count), POST /transactions/{id}/restore, DELETE /transactions/{id}/permanent and DELETE /transactions/deleted/empty. DELETE /transactions/{id} and POST /transactions/batch-delete move rows to the trash with their HTTP contracts unchanged. Nothing purges the trash automatically.
Cascade Rules
Section titled “Cascade Rules”- Institution → cascades to linked Accounts and their Snapshots (both soft-delete and restore)
- Account → cascades to linked Snapshots (restore only; soft-delete of account does not cascade-delete snapshots individually, they become inaccessible via the account filter)
Unique Constraints
Section titled “Unique Constraints”Soft-deletable entities use partial unique indexes (SQLite WHERE deleted_at IS NULL) instead of table-level UNIQUE constraints. This allows re-creation with the same name after soft-delete:
Index( "uq_institutions_user_name", "user_id", "name", unique=True, sqlite_where=text("deleted_at IS NULL"),)Archive Pattern
Section titled “Archive Pattern”Account and Institution also support archiving via archived_at: datetime | None. Archived records remain visible (unlike soft-deleted) but are visually dimmed and can be filtered. Archiving cascades to child entities:
| Action | Method | URL Pattern |
|---|---|---|
| Archive | POST |
/{entity}/{id}/archive |
| Unarchive | POST |
/{entity}/{id}/unarchive |
Locked Rows and Partial Batch Results
Section titled “Locked Rows and Partial Batch Results”A reconciled transaction is locked: its amount, type, date, account, status and deletion are refused with 409 and code transaction_locked. The detail carries transaction_id and the offending fields:
{"error": {"code": "transaction_locked", "message": "Transaction is reconciled", "detail": {"transaction_id": 42, "fields": ["amount"]}}}Every transaction writer goes through app/services/transaction_lock.py; a new write path must call it too (an AST test fails on an unguarded writer). A single-row write rejects the whole request. A bulk operation that can refuse some rows applies the rest and returns an additive skipped: [{id, reason}] list on its result (reason is locked, transfer_leg, currency_mismatch or duplicate, plus split_parent, which is reserved and never emitted yet; see the list below), so existing clients that ignore the key keep working. Follow this pattern for any new bulk operation that may refuse some rows.
Transfers and Bulk Edit
Section titled “Transfers and Bulk Edit”A transfer is two ordinary transactions that share an opaque transfer_group_id (see Database Architecture). The server mints the id: no request schema accepts it. Transaction responses carry it, together with transfer_peer (id, account_id and amount of the other leg), as additive keys.
| Endpoint | Purpose |
|---|---|
POST /api/v1/budget/transfers |
Creates the expense leg and the income leg in one request (201); received_amount is required across currencies |
POST /api/v1/budget/transfers/link |
Links two existing transactions (transaction_ids, exactly two) |
POST /api/v1/budget/transactions/{id}/unlink |
Turns both legs back into ordinary transactions |
GET /api/v1/budget/transfers/suggestions |
Likely pairs, computed on demand and read-only; paginated with limit/offset and X-Total-Count like any other list |
GET /api/v1/budget/savings-rate |
One object, {currency, months: [{month, income, expenses, transferred, rate}], missing_rate_currencies}, bounded by the optional date_from/date_to; like the series endpoints it has no limit/offset, and rate is null for a month without income or with a currency that has no exchange rate (listed in missing_rate_currencies) |
Errors are APIError responses with a specific code. An unknown or foreign account is a 404 account_not_found; the rest are 422:
| Error code | Raised when |
|---|---|
transfer_same_account |
Both legs would sit on the same account (on create, or when an edit moves one leg onto the other’s account) |
account_archived |
An archived account takes part in a transfer, or is the target of a bulk move (the whole batch is refused) |
transfer_not_eligible |
Two transactions cannot be linked; detail.reason says why |
transfer_not_linked |
Unlink is called on a transaction that is not a transfer leg |
transfer_type_locked |
The type of a transfer leg is edited; unlink first |
transfer_received_amount_required |
The accounts have different currencies and no received_amount was given |
transfer_amount_mismatch |
The accounts share a currency and received_amount differs from amount |
POST /transactions/batch-delete and POST /transactions/batch-update report every owned row they left alone in skipped: [{id, reason}], with these reasons:
locked: the row is reconciled and the request touches a locked field.transfer_leg: the row is a transfer leg and the request touches its date, account or status; a batch delete skips both legs unless both are selected and mutable.currency_mismatch: a move to another account would change the row’s currency.duplicate: a move would collide with an existing(account, external_id), trashed rows included.split_parent: reserved for split transactions; nothing emits it yet.
GET /api/v1/budget/transactions/{transaction_id}/similar lists the rows similar to one anchor transaction, so a client can offer one category for all of them. Similar means the same payee and transaction type, in any account and at any date, with an amount within the larger of 2.00 and 15% of the cluster’s median: the first band is centred on the anchor’s amount, the centre then moves once to the median of that band, and bands never chain. Only rows a merge accepts are listed (no trashed row, transfer leg, split parent, reconciled row or row without an account) and the anchor itself is left out. By default only uncategorized rows are listed; include_categorized=true also lists categorized rows with their category_id and subcategory_id, and either way they take part in the band. limit (1 to 250, default 50) and offset page the rows, ordered by date then id, newest first. The response carries payee_id, payee_name, transaction_type, median_amount (signed), total (the full count, whatever the page) and rows. An anchor that cannot cluster (no payee, a transfer leg, a split parent or no account) answers 200 with total 0 and no rows; an unknown, trashed or foreign anchor is a 404 transaction_not_found. The route only reads.
PATCH /api/v1/budget/budgets/{id} is deliberately stricter than before: monthly_limit must be positive, and an explicit null for category_id, monthly_limit, rollover_enabled or archived is a 422 that names the field (only subcategory_id may be null, which clears it). A client that sent 0 or null there now gets a 422.
Bank-File Imports and Rules
Section titled “Bank-File Imports and Rules”Bank files are imported in two steps: a parse endpoint that writes nothing and returns a preview, then a commit endpoint that creates the rows the client sends back. All four endpoints sit under /api/v1/budget/transactions/import/, which is what gives them the larger import body limit (see below).
| Endpoint | Purpose |
|---|---|
POST /api/v1/budget/transactions/import/parse |
Multipart upload of a .ofx, .qfx, .qif or .xml file (CAMT.052 or CAMT.053; the extension is matched case-insensitively). Returns one OfxImportPreview per statement; a CAMT file returns one per account and currency (statements of the same account and currency are merged), with source camt, no balance, and skipped_rows counting the booked entries it could not read or that are in another currency than their statement. QIF only reads three more form fields: qif_date_order (dmy by default, mdy or ymd), qif_number_format (1,234.56 by default, 1.234,56, 1 234,56, 1234.56 or 1234,56) and an optional account_id, used to flag the rows that account already holds (an unknown or foreign id is a 404 account_not_found); OFX, QFX and CAMT ignore all three |
POST /api/v1/budget/transactions/import/commit |
OfxImportCommitRequest (ofx_account_id, account_id, rows, relink), shared by OFX, QFX, QIF and CAMT. A QIF file has no bank identifier, so its commit sends an empty ofx_account_id, which never links or changes the account’s stored identifier |
POST /api/v1/budget/transactions/import/csv/parse |
Multipart upload of a .csv, .tsv or .txt file with a required account_id, an optional options and an optional preset. options is a JSON string holding a CsvImportMapping; preset is boursorama. The mapping used is options, else preset, else the account’s saved mapping, else an empty one. The response (CsvImportPreview) carries columns, the detected encoding and delimiter, the effective mapping, its mapping_source (request, saved, boursorama or none), mapping_missing_columns, the rows and their counts (ready, duplicates, errors, total). An empty or incomplete mapping still returns the columns |
POST /api/v1/budget/transactions/import/csv/commit |
CsvImportCommitRequest: account_id, rows (at most 20000), an optional mapping and remember_mapping (false by default) |
Both commit endpoints return the standard ImportSummary. The rows are created cleared, the user’s categorization rules that apply on import run on them, and a row whose external_id is already stored on the account is skipped and counted in duplicates_skipped, trashed rows included. A commit where every row is a duplicate is still a 200 with nothing created. ImportSummary.rules_timed_out lists {id, name} for each rule whose regular expression ran out of time during that run: the rule is skipped for the rest of the run and left unchanged, and the import itself succeeds. Rows still without a category after the rules take the category and subcategory of their payee’s default category; ImportSummary.categorized_from_payee counts the rows that did, only once the row was stored. That payee step runs after the rules, only on rows with no category and no subcategory, and reads the per-user suggest_category_from_payee switch. The Boursorama category import at /transactions/import-csv/ is a different feature and is not described here.
Each created row is linked to the payee whose name or alias matches its description after normalization (see Payees below), and the payee is created when none does. A row with a blank description gets no payee. The payee is set before the rules run, so a payee_id condition can match on import. Within one commit a text creates one payee, and a row that fails leaves no payee behind. The parse endpoints never write a payee: each row carries payee_name and payee_is_new, and each OFX/QIF/CAMT statement and the CSV preview carries new_payee_count, the distinct payees the commit would create (known duplicates, and CSV rows in error, are not counted). Commit ignores the echoed payee_name and payee_is_new and recomputes from the description. The Boursorama category import gives a transaction that has no payee the payee named by its CSV label; an existing payee is never replaced, and neither is one on a transaction whose label the user set by hand.
Limits shared by the importers:
- File size: 10 MB, enforced while the upload streams in (
413ofx_file_too_large). The CSV endpoints reuse this code. - Body size: the import routes accept a request body of up to
max_import_body_bytes(12 MB by default) while every other route stays atmax_request_body_bytes(2 MB). The larger limit is chosen from the path, so a new import endpoint must live under/transactions/import/(or/import/) to benefit from it. - Rows: 20000 per file and per commit (
MAX_IMPORT_ROWSinapp/services/import_parsing.py).
Text read from a file is kept verbatim. The importers never apply the export spreadsheet sanitiser, so a cell such as =SUM(A1) is stored as written.
Synthetic external ids
Section titled “Synthetic external ids”OFX and QFX rows keep the bank’s FITID. CSV and QIF rows have none, so the server builds csv1:<32 hex>:<k> and qif1:<32 hex>:<k>. The hex part is a SHA-256 prefix over the ISO date, the canonical signed amount, the normalised description and the normalised reference; k is the number of earlier rows of the same file with the same hash. The guarantees:
- Importing the same file twice yields the same ids, so the second import creates nothing.
- Two identical rows in one file stay distinct (
:0,:1), and a later export that overlaps an earlier one reproduces the ids of the shared rows even when the rows are reordered. - For CSV only the primary description column goes into the hash, so adding or removing an extra description column never changes an id.
- Rows with a zero amount or an unreadable date or amount are never given an id: they are reported as row errors (CSV) or counted in
skipped_rows(QIF).
CAMT rows keep the bank’s own entry reference (AcctSvcrRef, read per transaction detail, else per entry) as camt:<ref> when that reference is unique in the file; a row without one, or whose reference repeats in the file, gets camt1:<32 hex>:<k>, built like the CSV and QIF ids, so no two rows share an id and an id does not depend on the row’s position. Zero-amount CAMT entries are left out and get no id.
Import error codes
Section titled “Import error codes”All are APIError responses. Statuses are 422 unless noted.
| Error code | Raised when |
|---|---|
ofx_unsupported_format |
The OFX/QFX/QIF/CAMT parse endpoint gets an extension other than .ofx, .qfx, .qif or .xml |
ofx_no_statement |
The file holds no statement (OFX/QFX), no Stmt/Rpt element (CAMT) or no transaction record (QIF) |
ofx_unparseable_file |
An OFX or QFX file is corrupted or invalid |
ofx_file_too_large |
The upload is over 10 MB (413), on the OFX, QFX, QIF, CAMT and CSV endpoints |
ofx_account_conflict |
The OFX commit would link a bank identifier already linked to another account (409) |
account_not_found |
The target account is unknown or belongs to someone else (404), on every endpoint above that takes an account_id |
qif_unsupported_type |
The QIF file is an investment file (!Type:Invst) |
qif_multiple_accounts |
The QIF file holds transactions under more than one !Account block |
qif_too_many_rows |
The QIF file has more than 20000 transactions; detail.max is 20000 |
camt_unsafe_xml |
The CAMT file declares a DOCTYPE or entities; it is refused before anything is expanded |
camt_malformed |
The file is not well-formed XML, has an unreadable encoding, nests deeper than 32 levels, holds more than 1000000 elements or more than 100 statements, or a statement has no usable account id |
camt_not_camt |
The file is well-formed XML whose root is not a camt.052 or camt.053 Document |
camt_too_many_rows |
The CAMT file has more than 20000 rows; detail.max is 20000 |
csv_unsupported_format |
The CSV parse endpoint gets an extension other than .csv, .tsv or .txt |
csv_options_invalid |
options is not a valid CsvImportMapping JSON document |
csv_unreadable |
The CSV cannot be decoded, or holds no readable record |
csv_too_many_rows |
The CSV has more than 20000 rows; detail.max is 20000 |
csv_mapping_incomplete |
remember_mapping is true and the mapping does not map the date, the description and the amount (or both the inflow and outflow columns in split mode); nothing is created |
A CSV row that cannot be read is not an error response: the preview row carries error (date, amount or other) and a fixed English error_detail, and the client leaves it out of the commit.
Saved mapping and profile fields
Section titled “Saved mapping and profile fields”AccountOut.csv_import_mappingis read-only. It is not part ofAccountCreateorAccountUpdate, and a value sent there is ignored. The commit endpoint is the only writer: withremember_mappingit stores the validated mapping on the account (replacing the previous one), in the same transaction as the rows, and it does so even when every row was a duplicate. The parse endpoint never writes. The column is SQLNULLwhile nothing is saved.- A saved mapping is applied by header first: a column keeps its stored index when its header still matches (accents, case and spacing ignored), otherwise the first unused column with an equal header is taken, otherwise the header is listed in
mapping_missing_columns. A mapping sent inoptionsis authoritative and is not re-resolved. offer_rule_on_categorizeis a boolean onPATCH /api/v1/auth/me(wherefalseis a real change, not an absence) and on the user returned byGET /api/v1/auth/me. It only tells the UI whether to offer to create a rule after a manual categorization; the server does nothing with it.missing_rates_dismissedis the sorted, comma-joined set of currencies the user dismissed the missing-rates notice for, onPATCH /api/v1/auth/meand on the user returned byGET /api/v1/auth/me(nullwhen none).PATCHtakes three-letter uppercase codes separated by commas in any order (stored sorted and unique) or an empty string to clear; anything else is a 422, and omitting the field leaves it unchanged. The six exchange-rate mutation routes (create, update, delete, refresh, backfill-snapshots, fetch) clear it; the nightly refresh does not.setup_checklist_hiddenis a boolean onPATCH /api/v1/auth/me(wherefalseis a real change, not an absence) and on the user returned byGET /api/v1/auth/me; it only tells the web app to stop showing the Dashboard setup checklist.suggest_category_from_payeeis a boolean onPATCH /api/v1/auth/me(wherefalseis a real change, not an absence) and on the user returned byGET /api/v1/auth/me; it is the per-user switch of the payee category suggestion and defaults totrue.
Rule conditions
Section titled “Rule conditions”The condition tree of a categorization rule (conditions on POST and PATCH /api/v1/categorization-rules and on POST /api/v1/categorization-rules/preview) is a group of leaves and nested groups. A leaf is {kind: "leaf", field, op, value} (value_to for between).
- Operators:
contains,starts_with,equals,gt,lt,gte,lte,between,not_equals,not_contains,one_ofandmatches.one_of,not_containsandmatchesonly apply to thedescriptionandsuggested_labelfields (one_ofalso applies topayee_id). - List value:
one_oftakes a list of 1 to 20 non-blank strings of at most 200 characters (each is trimmed, and the comparison ignores case). A list with any other operator is a422. - Payee: the
payee_idfield takes onlyequalswith an integer payee id, orone_ofwith a list of payee ids written as digit strings (for example["7", "9"]). Any other operator, a non-integerequalsvalue or a non-digitone_ofvalue is a422. A transaction without a payee never matches. Ids are not checked for ownership when a rule is saved, so a rule naming a deleted or foreign payee never matches. - Regular expression:
matchestakes a non-empty pattern of at most 200 characters. The pattern is searched, not fully matched, and every search is capped at 50 ms. A text over 1000 characters is cut before the search. - Negation:
not_equalsandnot_containsare the exact negation ofequalsandcontainson the same data, so an empty field satisfies them. - Caps: a saved or previewed rule holds at most 30 nodes (every group, the root included, and every leaf counts for one) and nests at most 4 levels of groups (the root is level 1). The caps are checked when a rule is saved or previewed, never when a stored rule is evaluated, so a rule saved before the caps existed keeps matching.
- Save-time probe: creating or updating a rule compiles each
matchespattern and runs it against hostile sample texts, within 2 seconds in total. A preview only compiles. A pattern that fails either step is refused with a422before anything is stored. - Run-time timeout: when a pattern still exceeds 50 ms on real data, an import skips that rule for the rest of the run (
rules_timed_out),POST /api/v1/categorization-rules/previewstops the scan, returns the rows found so far and sets the response headerX-Rule-Timed-Out: true(absent otherwise), andPOST /api/v1/categorization-rules/{id}/applystops and returnsRuleApplyResult.timed_out = truewith the counts reached. A timeout never disables the stored rule. - Suggested label:
action_suggested_label(at most 255 characters, trimmed, blank stored asnull) is the label a matching rule writes. An explicitnullonPATCHclears it.
| Error code | Raised when |
|---|---|
rule_too_many_nodes |
The tree has more than 30 nodes; detail is {nodes, max_nodes} |
rule_too_deep |
Groups nest deeper than 4 levels; detail is {depth, max_depth}. The node count is checked first |
rule_regex_invalid |
A matches pattern does not compile or is refused by the size guards; detail is {path, reason} |
rule_regex_timeout |
A matches pattern is too slow in the save-time probe; detail is {path, timeout_ms} |
detail.path is the list of child indexes leading from the root group to the offending leaf, so a client can highlight the exact condition. reason is the regex library’s own message and never contains the pattern or any text.
Payees
Section titled “Payees”A payee is the merchant or counterparty a transaction points at. GET, POST, PATCH /{id} and DELETE /{id} on /api/v1/payees serve the caller’s own payees only: the id of another user’s payee is a 404 payee_not_found on every route.
- Normalization: a name is trimmed, every run of whitespace (tabs and non-breaking spaces included) collapses to one space, and it is cut to 255 characters. Two names are the same payee when they match ignoring case (Unicode case folding), so
Épicerieandépicerieare one payee. Migration 120 groups existing transactions with the same rule. - One namespace: a payee name and an alias never share a normalized key within a user. Creating a payee, or renaming one, to a name that is another payee’s name or alias is a
409payee_already_exists. - Rename: renaming keeps the old name as an alias of the same payee, so text that used the old name still resolves to it. A rename that only changes case or spacing stores no alias, and renaming a payee to one of its own aliases removes that alias.
- List:
limit(default 50, at most 250),offset,search(names and aliases, at most 100 characters),ids(repeatable, at most 100; only the caller’s payees come back),sort_by(nameorusage) andsort_dir(ascordesc). The total is inX-Total-Count. Each item carries itsaliases, itsusage_countand itsplanned_transaction_count. - Usage and delete:
usage_countcounts every transaction that uses the payee, trashed ones included, andplanned_transaction_countcounts the planned transactions that use it, trashed ones included.DELETEof a payee that any transaction or planned transaction uses is a409payee_in_usewithdetail.usage_countanddetail.planned_transaction_count; an unused payee is deleted together with its aliases (204). - Merge:
GET /api/v1/payees/merge-preview?payee_ids=(repeatable, 1 to 100) returns{transaction_count, planned_transaction_count}, the transactions and planned transactions that merging those payees would repoint, trashed ones included.POST /api/v1/payees/mergewith{survivor_payee_id, merged_payee_ids}(1 to 100 ids) repoints every one of them to the survivor and returns both counts withdeleted_payee_ids. The survivor is dropped frommerged_payee_ids; when nothing is left the answer is a422payee_merge_invalid, and an id of another user is a404payee_not_foundwith nothing changed. The merged names and their aliases become aliases of the survivor, a survivor without a default category takes the defaults of the first merged payee that has one, rule payee conditions naming a merged payee name the survivor, and the merged payees are deleted. The audit event ispayee.merged. - Category suggestion:
GET /api/v1/payees/{payee_id}/category-suggestionreturns{category_id, subcategory_id, source}, wheresourceisdefault(the payee’s own default category, when it still validates) orhistory(the category on at least 60% of the payee’s categorized live transactions, with at least 3 of them; transfers, trashed rows and split parents are not counted). The body is allnullwhen nothing is suggested or when the user’ssuggest_category_from_payeeswitch is off; an id of another user is a404payee_not_found, switch or not. The route only reads: no audit event, nothing is changed. - Default category:
default_category_idanddefault_subcategory_idare stored and returned. The importers apply them to rows still uncategorized after the rules (ImportSummary.categorized_from_payee), and the category suggestion route above serves them; manual transaction creation does not apply them. A category or subcategory the caller does not own is a404(category_not_found,subcategory_not_found), a subcategory of another category is a422category_subcategory_mismatch, an explicitnullcategory clears both, and changing the category without naming a subcategory drops the old subcategory. - Transactions: every transaction returned by
GET /api/v1/budget/transactionscarriespayee_idand a nestedpayee({id, name}), bothnullwhen the transaction has no payee. - Set and filter:
POSTandPATCH /api/v1/budget/transactionsacceptpayee_id. A payee of another user is a404payee_not_foundand nothing is written, an explicitnullclears the payee, and a non-null payee on a transfer leg is a422payee_on_transfer(clearing it, or sending the leg’s current payee back, is fine). A reconciled transaction keeps an editable payee. The list, ids, summary and CSV/ODS/XLSX export endpoints take a repeatablepayee_idquery parameter (at most 100); a payee of another user matches no row. - Planned transactions:
POSTandPATCH /api/v1/planned-transactionsacceptpayee_id(a payee of another user is a404payee_not_found, an explicitnullclears it, omitting it leaves it alone); the response carriespayee_idand a nestedpayee({id, name}). Every transaction a planned item generates is created with that payee; changing the payee later never touches transactions already posted. A backup carries it asplanned_transactions[].payee_name.
Monetary Values
Section titled “Monetary Values”Monetary values are stored as a Numeric column with fixed precision — most tables (PlannedTransaction, Budget, RecurringSnapshot, Snapshot, and similar) use Numeric(12, 2) — and are always serialized as strings in JSON responses.
{ "balance": "12500.00"}This precision isn’t universal: app/models/investment.py intentionally widens it for buy-price accuracy — Holding.quantity is Numeric(24, 8), and cost_basis/current_price are Numeric(20, 8) — and app/models/delta_cache.py’s delta/total use Numeric(14, 2). Check the specific model before assuming (12, 2).
Request schemas validate money fields against the column they feed through the Money, RateDecimal, QuantityDecimal and PriceDecimal types in app/utils/money.py: NaN, Infinity, and any value that does not fit the column (for Numeric(12, 2), an absolute value of 10^10 or more) are rejected with a 422, and extra decimal places are rounded half-up to the column scale. The import paths (spreadsheet commit, backup restore) apply the same rule through _validated_money in app/services/import_service.py and report a bad amount as a per-row ImportError instead of failing the request.
Never use floating-point numbers for monetary values, regardless of the column’s precision. JSON numbers are parsed as floats by most clients, leading to precision loss. Strings preserve exact decimal representation — this rule is universal even where the stored precision isn’t.
Idempotency Keys
Section titled “Idempotency Keys”A POST that is not naturally idempotent may accept an optional Idempotency-Key header (1 to 64 characters). A repeat with the same key is a replay: it returns the current state and records nothing again. Today only POST /api/v1/envelopes/{id}/pay uses it (the web app sends the envelope’s due date, so a double click cannot advance a recurring envelope twice).