Aurum already exposes the same REST API its frontend uses; this documents every endpoint (accounts, categories, tags, transactions incl. bulk CSV import, recurring, budgets, goals, assets/net worth, dashboard/cash-flow/ reports, insights/advice, settings, backup) with request/response shapes, enforced business rules, and curl recipes, so the API can be published and used to integrate other programs with Aurum. README links to it from the top nav and a new API section. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
25 KiB
Aurum API Documentation
Aurum exposes the same REST API its own frontend uses. Every action available in the UI — adding a transaction, creating an account, tagging an expense, importing a CSV statement, tracking an asset, setting a budget — can be done directly over HTTP. This makes it possible to script Aurum, feed it from another program (a bank-sync job, a bot, a shortcut on your phone), or pull your data into your own tools.
There is no SDK — it's a plain JSON REST API, callable with curl, any HTTP client library, or tools
like Postman/Insomnia.
Interactive, always-up-to-date docs are also built into the backend itself: once your instance is running, open
http://<host>:<port>/api/docs(Swagger UI) orhttp://<host>:<port>/api/redoc(ReDoc) for a live, "try it out" version of everything below.
Table of Contents
- Base URL & Authentication
- Conventions
- Errors
- Health Check
- Accounts
- Categories
- Tags
- Transactions
- Recurring Transactions
- Budgets
- Goals
- Assets & Net Worth
- Dashboard, Cash Flow & Reports
- Insights & Advice
- Settings
- Backup & Restore
- Recipes
Base URL & Authentication
Aurum ships as three containers (Postgres, FastAPI backend, nginx-served frontend). The frontend
container reverse-proxies /api/* straight through to the backend, so the API and the web UI share
the same host and port — whatever you set AURUM_WEB_PORT to in .env (default 3000):
http://<host>:<port>/api
For example, on a local install: http://localhost:3000/api. All endpoints below are relative to
this base URL — e.g. GET /transactions means GET http://localhost:3000/api/transactions.
Auth
Aurum has no built-in login system or API keys — it's designed for one person to self-host one private instance. Access control is whatever you put in front of it:
- Nothing set: if
AURUM_BASIC_AUTH_USER/AURUM_BASIC_AUTH_PASSWORDare empty in.env(the default), the API is completely open to anyone who can reach the host — no credentials needed. Fine forlocalhost-only or a private network; not fine on the public internet. - HTTP Basic Auth: set both
AURUM_BASIC_AUTH_USERandAURUM_BASIC_AUTH_PASSWORDin.envand restart (docker compose up -d). Every request — UI and API alike — then requires anAuthorization: Basic <base64(user:password)>header, or the equivalent-u user:passwordflag in curl.
# No auth configured
curl http://localhost:3000/api/accounts
# Basic Auth configured
curl -u myuser:mypassword http://localhost:3000/api/accounts
GET /api/health is always open (no auth), even with Basic Auth configured — it exists for Docker
healthchecks and uptime monitors.
There's no per-endpoint permission model beyond this: whoever can authenticate can read, create, update, and delete everything.
Conventions
- Format: all request and response bodies are JSON (
Content-Type: application/json). - IDs: integer, auto-incrementing, assigned by the server.
- Money fields (
amount,monthly_limit,value, ...): decimal numbers as JSON numbers or strings, up to 14 digits with 2 decimal places. Aurum is single-currency per account/asset — there is no built-in FX conversion. - Dates:
YYYY-MM-DD(ISO 8601 date, no time component). Timestamps (e.g. backupexported_at) are full ISO 8601 datetimes. - Colors: 6-digit hex strings, e.g.
"#4f46e5". - Partial updates: every
PATCHendpoint only touches the fields you actually send — omit a field and it's left unchanged. This matters for fields whose "clear this" value isnull: sending{"parent_id": null}explicitly clears a category's parent, while omittingparent_identirely leaves it as-is. - Enums are plain lowercase strings — see each resource's table below for the valid values.
Errors
Standard HTTP status codes:
| Code | Meaning |
|---|---|
200 / 201 |
Success (201 on POST that creates a resource) |
204 |
Success, no response body (deletes) |
400 |
Invalid request — a business rule was violated (e.g. wrong category kind, duplicate budget) |
401 |
Missing/invalid Basic Auth credentials (only when Basic Auth is configured) |
404 |
Resource not found |
422 |
Request body failed schema validation (wrong type, missing required field, out-of-range value) |
Error bodies follow FastAPI's default shape:
{ "detail": "Category 'Salary' is a income category and cannot be used for a expense transaction" }
422 validation errors carry a detail array with one entry per invalid field instead of a single
string.
Health Check
GET /api/health
No auth required. Returns {"status": "ok"}. Use this to check the backend is up before hitting
anything else.
Accounts
An account is a place money lives — a bank account, a card, a cash wallet. Every transaction belongs to exactly one account (transfers touch two). Balances are derived from transactions, not stored — there's no "set balance" endpoint.
AccountType: checking · savings · credit_card · cash · investment · other
| Method | Path | Description |
|---|---|---|
GET |
/accounts |
List accounts, with live balance. ?include_archived=true to include archived ones (excluded by default). |
POST |
/accounts |
Create an account. |
PATCH |
/accounts/{id} |
Update an account (partial). Set is_archived: true to archive instead of deleting. |
DELETE |
/accounts/{id} |
Delete an account and every transaction on it — irreversible. |
Create/update body:
{
"name": "Checking",
"type": "checking",
"currency": "USD",
"color": "#4f46e5"
}
name: required, 1–100 chars.type: optional, defaults tochecking.currency: optional 3-letter code, defaults toUSD. Purely a display label — not validated against ISO 4217 and not used for conversion.color: optional hex color.is_archived(update only): archive/unarchive without deleting. An archived account is hidden from the default account list but its transactions and balance remain intact.
Response (AccountWithBalance):
{
"id": 1,
"name": "Checking",
"type": "checking",
"currency": "USD",
"color": "#4f46e5",
"is_archived": false,
"balance": "1523.40"
}
Categories
Categories classify transactions as income or expense, with one optional level of subcategories
(a subcategory's parent_id points at a top-level category of the same kind — no deeper nesting).
A handful of default categories are seeded on first run and can't be deleted (is_default: true),
though they can be renamed, recolored, and reordered.
CategoryKind: income · expense
| Method | Path | Description |
|---|---|---|
GET |
/categories |
List categories, sorted by sort_order. ?kind=income or ?kind=expense to filter. |
POST |
/categories |
Create a category (or subcategory, via parent_id). |
PATCH |
/categories/{id} |
Update a category (partial). |
DELETE |
/categories/{id} |
Delete a category. Fails with 400 if it's a default category. |
Create body:
{
"name": "Groceries",
"kind": "expense",
"icon": "shopping-cart",
"color": "#22c55e",
"sort_order": 0,
"parent_id": null
}
name: required, 1–100 chars.kind: required,incomeorexpense. Fixed at creation — there's no way to flip a category's kind afterward (create a new one instead).icon: optional, free-form string (the frontend maps these to an icon set).color: required hex color.sort_order: optional int controlling display order, defaults to0.parent_id: optional. Rules enforced server-side: the parent must itself be top-level (no grandchildren), must share the child'skind, and a category can't be its own parent.
Update accepts the same fields, all optional. Note: to turn a subcategory back into a top-level
category, send "parent_id": null explicitly — omitting the field leaves the existing parent as-is.
A category that already has subcategories of its own can't be turned into a subcategory.
Response (CategoryRead): same fields as create, plus id and is_default.
Tags
Free-form labels a transaction can carry any number of. Case-insensitive dedup on creation: posting
"Georgia" when "georgia" already exists returns the existing tag instead of making a duplicate.
| Method | Path | Description |
|---|---|---|
GET |
/tags |
List all tags, alphabetically. |
POST |
/tags |
Create a tag (or return the existing one, case-insensitive match). |
DELETE |
/tags/{id} |
Delete a tag — removes it from every transaction that had it. |
Create body: {"name": "Vacation"} (1–50 chars).
Response (TagRead): {"id": 1, "name": "Vacation"}.
Transactions
The core resource. A transaction is income, expense, or transfer.
TransactionType: income · expense · transfer
| Method | Path | Description |
|---|---|---|
GET |
/transactions |
Paginated, filterable list. See query params below. |
GET |
/transactions/years |
Every year with data (plus the current year), for a year picker. Returns [2024, 2025, 2026]. |
POST |
/transactions |
Create one transaction. |
POST |
/transactions/bulk |
Create up to 5000 transactions in one all-or-nothing batch (CSV import). |
PATCH |
/transactions/{id} |
Update a transaction (partial). |
DELETE |
/transactions/{id} |
Delete a transaction. |
GET /transactions query params
| Param | Type | Notes |
|---|---|---|
year |
int | 2000–2100 |
month |
int | 1–12 |
start_date / end_date |
date | Inclusive range, combinable with year/month |
account_id |
int | |
category_id |
int | |
tag_id |
int | |
type |
income|expense|transfer |
|
search |
string | Substring match (case-insensitive) against description, merchant, and notes |
sort |
date_desc|amount_desc|amount_asc |
Default date_desc |
page |
int | Default 1 |
page_size |
int | Default 20, max 200 |
Response (TransactionPage):
{
"items": [ /* TransactionRead[] */ ],
"total": 143,
"page": 1,
"page_size": 20
}
Create body (TransactionCreate)
{
"account_id": 1,
"category_id": 4,
"transfer_account_id": null,
"type": "expense",
"amount": 42.50,
"description": "Groceries",
"merchant": "Whole Foods",
"notes": null,
"date": "2026-08-29",
"tag_ids": [3, 7]
}
Field rules, enforced server-side:
account_id: required, the account the money moves through.type: required. Drives which other fields are valid:income/expense:category_idoptional but must point at a category of the matchingkindif given (anincometransaction can't use anexpensecategory).transfer_account_idmust be omitted.transfer:transfer_account_idis required and must differ fromaccount_id.category_idmust be omitted — transfers aren't categorized.
amount: required,> 0(transfers/expenses aren't signed negative — direction comes fromtype), up to 14 digits, 2 decimal places.description: required, 1–255 chars. Auto-capitalized server-side (first letter uppercased).merchant: optional, up to 150 chars.notes: optional, free text.date: required.tag_ids: optional list of existing tag IDs. Unknown IDs return400.
Update (TransactionUpdate) accepts the same fields, all optional, plus the same
type/category/transfer consistency rules applied to the effective (merged) values. tag_ids: omit
to leave tags untouched; send (even []) to replace the full tag set.
Response (TransactionRead): the input fields plus id, and the nested account
(AccountRead), category (CategoryRead or null), tags (TagRead[]).
Bulk create (POST /transactions/bulk)
Used by the CSV import wizard, but callable directly for any bulk load (e.g. syncing from a bank export tool). All rows are validated before any is inserted — one bad row fails the whole request with no partial import.
{ "items": [ /* 1–5000 TransactionCreate objects */ ] }
Response: {"created": 250}.
Recurring Transactions
Templates for bills/income that repeat on a schedule. Nothing posts automatically in the
background — a transaction is only created when you call the /post endpoint (or click "Post" in
the UI). This is deliberate: a missed week never silently back-fills a pile of transactions.
RecurringFrequency: weekly · monthly · yearly
| Method | Path | Description |
|---|---|---|
GET |
/recurring |
List all recurring templates, with computed due-date info. |
POST |
/recurring |
Create a template. |
PATCH |
/recurring/{id} |
Update a template (partial). Set is_active: false to pause. |
DELETE |
/recurring/{id} |
Delete a template (does not touch already-posted transactions). |
POST |
/recurring/{id}/post |
Create a real transaction from the template, dated today, and advance the schedule. |
Create body: same shape as TransactionCreate minus date/tag_ids, plus:
{
"account_id": 1,
"category_id": 4,
"type": "expense",
"amount": 15.99,
"description": "Netflix",
"frequency": "monthly",
"anchor_date": "2026-01-05"
}
anchor_date is the first due date; each /post call advances last_posted_date and recomputes
next_due_date (monthly clamps to the shortest month, e.g. day 31 → day 28/29/30; yearly Feb 29
falls back to Feb 28 in non-leap years).
Response (RecurringTransactionRead) adds computed fields: next_due_date, is_due (boolean),
days_until_due (negative if overdue), plus denormalized account_name/category_name/etc. for
display without extra lookups.
Budgets
One monthly spending limit per expense category (income categories can't be budgeted; each category can have at most one budget).
| Method | Path | Description |
|---|---|---|
GET |
/budgets |
List all budgets. |
GET |
/budgets/status |
Budgets vs. actual spend for a month. ?year=&month= (default: current month). |
POST |
/budgets |
Create a budget. 400 if the category already has one, or isn't an expense category. |
PATCH |
/budgets/{id} |
Update monthly_limit. |
DELETE |
/budgets/{id} |
Delete a budget. |
Create body: {"category_id": 4, "monthly_limit": 500}.
GET /budgets/status response:
{
"year": 2026,
"month": 8,
"items": [
{
"budget_id": 1,
"category_id": 4,
"category_name": "Groceries",
"category_color": "#22c55e",
"category_icon": "shopping-cart",
"monthly_limit": "500.00",
"spent": "612.30",
"remaining": "-112.30",
"percent": 122.46,
"is_over_budget": true
}
]
}
percent can exceed 100 on purpose — clamp it client-side if you're rendering a progress bar.
Goals
Savings goals with a running contribution log. current_amount is always the sum of every logged
contribution — there's no separate "set balance" call.
| Method | Path | Description |
|---|---|---|
GET |
/goals |
List all goals with computed progress. |
POST |
/goals |
Create a goal. |
PATCH |
/goals/{id} |
Update name/target_amount/target_date (partial). |
DELETE |
/goals/{id} |
Delete a goal and its contribution log. |
POST |
/goals/{id}/contributions |
Log a contribution (or a withdrawal). |
Create body: {"name": "Emergency fund", "target_amount": 10000, "target_date": "2027-01-01"}
(target_date optional).
Add contribution: {"amount": 250, "date": "2026-08-29", "note": "Bonus"}. amount may be
negative (a withdrawal against the goal) but not zero.
Response (GoalRead):
{
"id": 1,
"name": "Emergency fund",
"target_amount": "10000.00",
"target_date": "2027-01-01",
"current_amount": "3250.00",
"remaining": "6750.00",
"percent": 32.5,
"is_reached": false
}
Assets & Net Worth
Assets are manually tracked, non-cash net-worth components — investments, crypto, real estate,
vehicles, precious metals, etc. Cash is not an asset; it's derived automatically from account
balances and shows up in the net worth summary alongside assets, not as an Asset row.
Each asset has a value history (AssetValuation rows, one per date) rather than a single
number — creating an asset seeds its first valuation, and you add more over time to track
appreciation/depreciation.
AssetClass: investments · crypto · real_estate · vehicles · precious_metals · other
CapitalRole (how it behaves month to month — user-tagged, not inferred): income (e.g. a
rented-out apartment) · neutral (e.g. a laptop used for work) · drain (e.g. a depreciating
personal vehicle)
RiskLevel (risk of loss — user-tagged): low · medium · high
| Method | Path | Description |
|---|---|---|
GET |
/assets |
List assets, each with its current (latest) value. |
POST |
/assets |
Create an asset, seeding its first valuation. |
PATCH |
/assets/{id} |
Update asset metadata (not its value — use the valuations endpoint). |
GET |
/assets/{id}/valuations |
Full value history, oldest first. |
POST |
/assets/{id}/valuations |
Record a value as of a date. Re-posting the same date updates that day's value (upsert) instead of erroring. |
DELETE |
/assets/{id} |
Delete an asset and its whole valuation history. |
GET |
/net-worth/summary |
Aggregated net worth: timeline, breakdown by class, by capital role, by risk level. |
Create asset body:
{
"name": "Brokerage account",
"asset_class": "investments",
"currency": "USD",
"notes": null,
"capital_role": "income",
"monthly_cash_flow": 0,
"risk_level": "high",
"value": 25000,
"as_of_date": "2026-08-29"
}
Add valuation body: {"value": 26500, "as_of_date": "2026-08-30"}.
GET /net-worth/summary — ?range= one of 7d, 30d, 90d, 180d, 365d, all (default
30d):
{
"range": "30d",
"current": "48250.00",
"change_amount": "1200.00",
"change_percent": 2.55,
"series": [ { "date": "2026-08-01", "value": "47050.00" } ],
"breakdown": [
{ "key": "cash", "name": "Cash", "color": "#...", "icon": "wallet", "amount": "5250.00", "percent": 10.88 }
],
"capital_roles": [
{ "role": "income", "label": "Income", "color": "#...", "total_value": "25000.00", "monthly_cash_flow": "0.00", "count": 1 }
],
"risk_levels": [
{
"risk_level": "low", "label": "Low", "color": "#...",
"total_value": "5250.00", "percent": 10.88,
"items": [ { "key": "cash", "name": "Cash", "amount": "5250.00", "percent": 100.0 } ]
}
]
}
Dashboard, Cash Flow & Reports
Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data into an external dashboard without recomputing it yourself.
| Method | Path | Description |
|---|---|---|
GET |
/dashboard/summary |
One month's headline numbers + spending by category. ?year=&month= (default: current month). |
GET |
/cash-flow |
Income vs. expense, month by month. ?start_date=&end_date= (default: no bound, i.e. all history). |
GET |
/reports/category-spending |
One category's spend over time. ?category_id= (required) &start_date=&end_date=. |
GET |
/reports/category-ranking |
All categories ranked by total spend over a period. ?kind=expense|income (default expense) &start_date=&end_date=. |
GET /dashboard/summary response:
{
"year": 2026, "month": 8,
"real_income": "5200.00", "spent": "3120.45", "net": "2079.55", "transferred_out": "500.00",
"spending_by_category": [
{ "category_id": 4, "name": "Groceries", "color": "#22c55e", "icon": "shopping-cart", "amount": "612.30", "percent": 19.6 }
]
}
GET /cash-flow response:
{
"start_date": null, "end_date": null,
"points": [ { "year": 2026, "month": 7, "income": "5200.00", "expense": "3400.00", "net": "1800.00" } ],
"total_income": "62400.00", "total_expense": "40800.00", "total_net": "21600.00"
}
Insights & Advice
Rules-based, computed on read — no ML, no background jobs. Both read from the same transaction/ account/asset data as everything else; thresholds are configurable via Settings.
| Method | Path | Description |
|---|---|---|
GET |
/insights/alerts |
Active proactive alerts (negative cash flow streak, declining net worth, over-budget category, risky allocation, idle cash). |
GET |
/advice |
Plain-language observations (rising spending categories, unbudgeted top expenses, savings-rate trend). |
Both return a key + machine-readable params per item rather than pre-rendered text — the
frontend interpolates a localized message client-side, so build your own message from key/params
if you're consuming this programmatically rather than trying to parse rendered strings.
// GET /insights/alerts
{ "alerts": [ { "key": "idle_cash", "severity": "warning", "params": { "account_id": 2, "days": 75 } } ] }
// GET /advice
{ "items": [ { "key": "rising_category", "tone": "warning", "params": { "category": "Dining", "percent": 34.2 } } ] }
Settings
App-wide configuration: display currency and every alert threshold used by /insights/alerts.
Single row, created automatically on first run — there's nothing to create, only to read/update.
| Method | Path | Description |
|---|---|---|
GET |
/settings |
Read current settings. |
PATCH |
/settings |
Update settings (partial). |
{
"currency": "USD",
"negative_cash_flow_threshold_months": 2,
"net_worth_decline_threshold_months": 2,
"risky_allocation_threshold_percent": 20,
"idle_cash_threshold_amount": "1000.00",
"idle_cash_threshold_days": 60
}
currency: 3-letter uppercase code — display-only, no conversion.negative_cash_flow_threshold_months/net_worth_decline_threshold_months: consecutive months before the corresponding alert fires (1–24).risky_allocation_threshold_percent: max % of total capital allowed in medium/high risk tiers beforerisky_allocation_exceededfires (1–100).idle_cash_threshold_amount/idle_cash_threshold_days: balance + days of no activity a depository account needs to hit beforeidle_cashfires.
Backup & Restore
A full snapshot of every table as one JSON document — the same mechanism the in-app Settings → Backup & Restore uses. Good for scripted off-site backups, or for migrating data programmatically.
| Method | Path | Description |
|---|---|---|
GET |
/backup/export |
Download a full backup as JSON. |
POST |
/backup/import |
Restore from a backup file — replaces existing data, all-or-nothing. |
# Export
curl -u user:pass http://localhost:3000/api/backup/export -o aurum-backup.json
# Restore
curl -u user:pass -X POST http://localhost:3000/api/backup/import \
-H "Content-Type: application/json" \
-d @aurum-backup.json
The payload includes an aurum_backup_version field checked on import — a file from an incompatible
future format is rejected outright rather than partially applied. Treat /backup/import as
destructive: back up your current data first if you're experimenting.
Recipes
Add an expense transaction
curl -u user:pass -X POST http://localhost:3000/api/transactions \
-H "Content-Type: application/json" \
-d '{
"account_id": 1,
"category_id": 4,
"type": "expense",
"amount": 12.50,
"description": "Coffee",
"date": "2026-08-30"
}'
Add income with a tag
# 1. Find or create the tag
curl -u user:pass -X POST http://localhost:3000/api/tags \
-H "Content-Type: application/json" -d '{"name": "Freelance"}'
# -> {"id": 9, "name": "Freelance"}
# 2. Create the transaction referencing it
curl -u user:pass -X POST http://localhost:3000/api/transactions \
-H "Content-Type: application/json" \
-d '{
"account_id": 1,
"category_id": 2,
"type": "income",
"amount": 800,
"description": "Client invoice",
"date": "2026-08-30",
"tag_ids": [9]
}'
Move money between two of your own accounts
curl -u user:pass -X POST http://localhost:3000/api/transactions \
-H "Content-Type: application/json" \
-d '{
"account_id": 1,
"transfer_account_id": 2,
"type": "transfer",
"amount": 500,
"description": "Move to savings",
"date": "2026-08-30"
}'
Pull this month's spend by category (for an external dashboard)
curl -u user:pass "http://localhost:3000/api/dashboard/summary" | jq '.spending_by_category'
Bulk-import transactions from your own data source
curl -u user:pass -X POST http://localhost:3000/api/transactions/bulk \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "account_id": 1, "type": "expense", "amount": 9.99, "description": "Spotify", "date": "2026-08-01" },
{ "account_id": 1, "type": "expense", "amount": 45.00, "description": "Gas", "date": "2026-08-03" }
]
}'
For self-hosting, environment variables, and running Aurum itself, see README.md.