Adds the analytics half of the CoinMarketCap reference: a value-over-time chart (GET /crypto/history, forward-filled from AssetValuation like Net Worth's own series but scoped to crypto assets), an allocation breakdown by coin, and stat tiles for all-time profit, total invested (cost basis), and best/worst performer. Deliberately no "24h" range — history is only as dense as the sync cadence (button or once/day), so a 24h chart would be one or two points, not a smooth intraday line. Cost basis / all-time profit / best-worst performer aren't separate endpoints: they're trivial to derive client-side from fields already in GET /crypto/holdings, so no redundant backend logic. Allocation card follows the dataviz skill's categorical rule: top 7 coins by value get a fixed-order color from the app's already-validated 8-slot ramp, an 8th+ folds into "Other" rather than generating a new hue. 82/82 backend tests passing (3 new for /crypto/history). Verified live against the real CoinGecko API — and caught a real holding the user had already added directly through the UI between messages, which the smoke test correctly left untouched while cleaning up only its own test data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
31 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
- Crypto
- 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 } ]
}
]
}
Crypto
Live-priced crypto holdings with a full buy/sell history — a CoinMarketCap-style portfolio view, kept
current against CoinGecko's Demo API. Under the hood each
holding is an Asset (asset_class=crypto, see Assets & Net Worth) — deleting
one is DELETE /assets/{asset_id}, not a separate endpoint, and it shows up in /net-worth/summary
like any other asset automatically.
Quantity and average buy price are never sent directly — they're derived from a log of buy/sell transactions, using the weighted-average-cost method: a buy blends into the running average cost; a sell reduces quantity but leaves the average cost of what's still held unchanged. This is the same "record events, derive the total" shape as Goals' contribution log.
Requires AURUM_COINGECKO_API_KEY in .env (a free Demo key, no card required). Endpoints that need
CoinGecko (/crypto/holdings on creation, /crypto/refresh, /crypto/search) return 400 with a
message telling you so if it's unset — adding a transaction to an existing holding never needs it at
all (see below).
Prices are never fetched in the background — there's no scheduler in this stack. Two triggers
only: POST /crypto/refresh (a manual "refresh now"), and a lazy check on every GET /crypto/holdings that only actually calls CoinGecko once 24h have passed since the last successful
sync. If CoinGecko is unreachable, existing values are left untouched and error_key is set instead
of the whole request failing.
| Method | Path | Description |
|---|---|---|
GET |
/crypto/holdings |
List holdings with live price, 1h/24h/7d % change, computed quantity/avg buy price/P&L. Also runs the lazy once-a-day auto-refresh. |
POST |
/crypto/refresh |
Force a price refresh right now, bypassing the 24h window. |
POST |
/crypto/holdings |
Add a new holding — its first buy transaction, inline. Fetches today's price immediately so it isn't null until the next sync. |
POST |
/crypto/holdings/{asset_id}/transactions |
Buy more of, or sell some of, a coin already tracked. Never calls CoinGecko — value is recomputed from the last cached price. 400 if a sell would exceed what's currently held. |
GET |
/crypto/holdings/{asset_id}/transactions |
Full buy/sell history for one holding, newest first. |
DELETE |
/crypto/transactions/{transaction_id} |
Remove one transaction; quantity/avg buy price/value are recomputed from what's left. |
GET |
/crypto/search |
?q= — search CoinGecko for a coin to add (name/ticker, returns its coingecko_id + logo). |
GET |
/crypto/history |
?range=7d|30d|90d|all (default 30d) — total crypto holdings value over time, for the portfolio chart. No 24h — resolution is only as dense as the sync cadence above. |
Create body (POST /crypto/holdings):
{
"coingecko_id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"thumb_url": "https://...",
"quantity": "0.05",
"price_per_unit": "55000",
"date": "2026-08-01",
"note": null
}
coingecko_id is CoinGecko's own stable id (not the ticker — tickers collide across unrelated
coins) — get it from /crypto/search rather than guessing. quantity/price_per_unit support up to
18 decimal places (wei-level token amounts). price_per_unit is what you actually paid, in the app's
display currency — it's stored as-is, never re-derived from market data later.
Add a transaction (POST /crypto/holdings/{asset_id}/transactions):
{ "type": "sell", "quantity": "0.02", "price_per_unit": "61000", "date": "2026-08-30", "note": null }
type is "buy" or "sell".
GET /crypto/holdings / POST /crypto/refresh response:
{
"synced": true,
"last_synced_at": "2026-08-30T12:00:00Z",
"error_key": null,
"holdings": [
{
"asset_id": 7,
"coingecko_id": "bitcoin",
"symbol": "BTC",
"name": "Bitcoin",
"thumb_url": "https://...",
"quantity": "0.05",
"avg_buy_price": "55000.00",
"current_price": "61000.00",
"price_change_1h": "0.12",
"price_change_24h": "2.30",
"price_change_7d": "-1.80",
"value": "3050.00",
"cost_basis": "2750.00",
"profit_loss": "300.00",
"profit_loss_percent": 10.91
}
]
}
current_price/value are null only for a holding whose very first price fetch failed (CoinGecko
was down right when it was added) — distinct from a real 0. avg_buy_price/cost_basis/
profit_loss/profit_loss_percent are null once a holding's quantity has been fully sold down to
zero (nothing left to have a cost basis). error_key is "unreachable" when a sync attempt couldn't
reach CoinGecko (existing values are kept as-is), or null otherwise.
GET /crypto/history response:
{
"range": "30d",
"current": "9727.68",
"change_amount": "1240.16",
"change_percent": 14.6,
"series": [
{ "date": "2026-08-01", "value": "8487.52" },
{ "date": "2026-08-02", "value": "8487.52" }
]
}
One point per calendar day, forward-filled from whatever AssetValuation snapshots actually exist
(same technique as /net-worth/summary's own series, just scoped to crypto-class assets) — a day
with no sync simply repeats the last known total rather than leaving a gap. Total invested (cost
basis), all-time profit/loss, and best/worst performer aren't separate endpoints — they're trivial to
derive client-side by summing/comparing the value/cost_basis/profit_loss_percent fields already
in every GET /crypto/holdings response.
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.