Files
aurum/DOCS.md
ZProger c3309b9d60 feat: click a coin row to view its trade history; edit transactions
Matches CoinMarketCap's flow: clicking anywhere on a coin's row in the
holdings table opens its transaction history (previously only a dedicated
icon button did). The table's own History icon is gone since the row now
does that job; the remaining "+"/delete icons get stopPropagation so they
don't also trigger the row click.

Adds the missing piece: transactions could be viewed and deleted but not
edited. New PATCH /crypto/transactions/{id} (partial update, never calls
CoinGecko, same oversell guard as creating a sell) backs a pencil button on
each history row that opens CryptoTransactionModal pre-filled — the same
dialog used for "+", now doing double duty as create/edit.

86/86 backend tests passing (4 new). Verified live against the real API:
create, edit quantity/note, 404 on unknown id, cleanup — real user data
untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
2026-08-30 05:06:20 +03:00

31 KiB
Raw Blame History

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) or http://<host>:<port>/api/redoc (ReDoc) for a live, "try it out" version of everything below.

Table of Contents

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_PASSWORD are empty in .env (the default), the API is completely open to anyone who can reach the host — no credentials needed. Fine for localhost-only or a private network; not fine on the public internet.
  • HTTP Basic Auth: set both AURUM_BASIC_AUTH_USER and AURUM_BASIC_AUTH_PASSWORD in .env and restart (docker compose up -d). Every request — UI and API alike — then requires an Authorization: Basic <base64(user:password)> header, or the equivalent -u user:password flag 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. backup exported_at) are full ISO 8601 datetimes.
  • Colors: 6-digit hex strings, e.g. "#4f46e5".
  • Partial updates: every PATCH endpoint only touches the fields you actually send — omit a field and it's left unchanged. This matters for fields whose "clear this" value is null: sending {"parent_id": null} explicitly clears a category's parent, while omitting parent_id entirely 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 to checking.
  • currency: optional 3-letter code, defaults to USD. 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, income or expense. 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 to 0.
  • parent_id: optional. Rules enforced server-side: the parent must itself be top-level (no grandchildren), must share the child's kind, 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_id optional but must point at a category of the matching kind if given (an income transaction can't use an expense category). transfer_account_id must be omitted.
    • transfer: transfer_account_id is required and must differ from account_id. category_id must be omitted — transfers aren't categorized.
  • amount: required, > 0 (transfers/expenses aren't signed negative — direction comes from type), 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 return 400.

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.
PATCH /crypto/transactions/{transaction_id} Edit an existing transaction (partial — send only the fields you're changing). Never calls CoinGecko. 400 if changing a sell's quantity would exceed what the rest of the log leaves held.
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 before risky_allocation_exceeded fires (1–100).
  • idle_cash_threshold_amount / idle_cash_threshold_days: balance + days of no activity a depository account needs to hit before idle_cash fires.

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.