API routes
Every /v1 route the API serves today.
Base: the server’s own address (locally http://127.0.0.1:8000 with dev.sh, or :8080 for the compose
stack). Every route needs Authorization: Bearer <session token> except those marked “no sign-in”. In a
browser, only the server’s own origin is allowed (403 origin_not_allowed otherwise). The OpenAPI schema is at
/v1/openapi.json.
Conventions: money in integer minor units; ids are UUIDs; editable rows carry version (mismatch → 409); creates
take a client-made id and are idempotent; errors are {"detail": "<code>"}. Within /v1 routes and fields are
only added.
The server and reference data
| Method | Path | Notes |
|---|---|---|
| GET | /v1/health |
{"ok":true}; no sign-in |
| GET | /v1/instance |
Name, server_version, api_version, signup (invite/open), setup_needed; no sign-in. Clients check it first |
| GET | /v1/currencies |
ISO 4217 codes and decimals |
Getting in (no sign-in)
| Method | Path | Notes |
|---|---|---|
| POST | /v1/setup |
The first account, with the setup code from the log; becomes admin. Optional server_name |
| POST | /v1/setup/restore |
Multipart: setup_code, file (a backup), optional purged (its purged_accounts.jsonl) → 202; the server restarts and restores it. The code isn’t used up. 403 setup_invalid once there’s an admin |
| POST | /v1/auth/invite-check |
{"code"} → {"valid"}, for the join screen |
| POST | /v1/auth/sign-up |
Username, password, language (en or vi; en if left out), timezone, invite_code (unless sign-up is open) → token |
| POST | /v1/auth/sign-in |
Username, password, code (TOTP or recovery code) when two-factor is on, remember_device with it, device_token from an earlier remember → token, and device_token when asked |
| POST | /v1/auth/reset |
A reset link’s token and a new password → token; turns two-factor off |
Sign-in security (signed in)
| Method | Path | Notes |
|---|---|---|
| POST | /v1/auth/sign-out |
Ends this session |
| POST | /v1/auth/sign-out-others |
Ends every other session and forgets every other remembered device; send this device’s device_token to keep it |
| GET | /v1/auth/sessions |
Signed-in devices: id, device (Chrome · Windows, or null), created_at, last_used_at, current; this one first. Never a token |
| DELETE | /v1/auth/sessions/{id} |
Ends that session (404 for one that isn’t yours) |
| POST | /v1/auth/password |
Current and new password; ends other sessions |
| GET | /v1/auth/two-factor |
enabled, recovery_codes_left |
| POST | /v1/auth/two-factor/setup |
A new secret, its otpauth:// URI and QR code; nothing saved yet |
| POST | /v1/auth/two-factor/enable |
Secret and a code from it → ten recovery codes |
| POST | /v1/auth/two-factor/recovery-codes |
A current code → new recovery codes |
| POST | /v1/auth/two-factor/disable |
A current code |
Me (the signed-in user)
| Method | Path | Notes |
|---|---|---|
| GET | /v1/me |
Profile, including is_admin and idle_sign_out_minutes |
| PATCH | /v1/me |
Username, display name, language, timezone (last write wins); idle_sign_out_minutes (1 to 1440, 5 by default, or null for never; left out leaves it) |
| GET | /v1/me/status |
Open, or closed with deletion date |
| POST | /v1/me/delete |
Close the account (30-day restore) |
| POST | /v1/me/restore |
Restore a closed account |
| GET | /v1/me/export |
ZIP of readable CSVs |
The server’s admin
| Method | Path | Notes |
|---|---|---|
| PATCH | /v1/admin/instance |
Server name, signup mode |
| GET | /v1/admin/users |
Accounts with last seen, status, two-factor |
| POST | /v1/admin/users/{id}/disable |
{"disabled": true/false}; disabling ends their sessions |
| POST | /v1/admin/users/{id}/reset |
A one-time reset token (24 hours) |
| GET, POST | /v1/admin/invites |
POST: note, days (1 to 30), server (the admin’s origin) → code, link, QR |
| DELETE | /v1/admin/invites/{id} |
Revoke |
| GET | /v1/admin/jobs |
The daily jobs (purge, backup, rates) in the order they run: at_minute and timezone (null: JOBS_HOUR), default_minute, enabled, next_run_at, and the last run (last_started_at, last_finished_at, last_result done/failed, last_detail) |
| PATCH | /v1/admin/jobs/{name} |
at_minute (0 to 1439) with its timezone, or null for JOBS_HOUR; enabled (only rates can be off: 422 invalid); what’s left out stays |
| GET | /v1/admin/update |
Running and newest version, whether the updater runs (a recent running status counts), its last status (null when that’s from before the server started: an update by hand or a restart came since), changelog_url (from PROJECT_SITE_URL) |
| POST | /v1/admin/update |
Ask the updater to update now (409 updater_off without it) |
| GET, POST | /v1/admin/restore |
GET: the last restore from the web app (status requested/running/done/failed, at, detail), or null. POST, multipart: file, optional purged, password, code when two-factor is on, confirm (true) → 202; the server restarts and restores it |
Books and their contents
| Method | Path | Notes |
|---|---|---|
| GET, POST | /v1/books |
POST creates with starter categories unless unticked |
| GET, PATCH, DELETE | /v1/books/{book_id} |
|
| GET | /v1/books/{book_id}/changes |
{"marker"}: changes whenever anything in the book does, by anyone. Poll it and refetch the book when it differs (the web app does every 5 seconds while it’s on screen) |
| GET | /v1/books/{book_id}/dashboard |
Everything the home screen shows, in one call, with rates_source (some sources need a credit) |
| GET, POST | /v1/books/{book_id}/accounts |
Each with its balance, converted_balance (in the book’s currency at today’s rate; null without a rate) and pinned (on this user’s Overview). POST takes an opening balance, optional contact_id and the details by kind. People’s own accounts are listed too (kind person, contact_id = their id); POST can’t make one |
| PATCH | /v1/books/{book_id}/accounts/{id} |
Includes archive. A detail left out stays; null clears it. A person’s own account answers person_account |
| PUT, DELETE | /v1/books/{book_id}/accounts/{id}/pin |
Pin it to this user’s Overview, or unpin it (204). Each member’s own; a viewer can too |
| GET | /v1/books/{book_id}/accounts/{id}/bills |
A credit card’s or loan’s bills today (book’s timezone), or []: a card’s last statement while it has something to pay, then the next statement so far (statement_open: true, with only what’s new in it); a loan’s installment. Each: kind (card/loan), period_start, period_end, due_on, amount, paid, remaining, owed_now, interest and fee still to record with their fixed interest_transaction_id and fee_transaction_id, pay_from (the account of the last payment), statement_open; a card’s also installments the statement bills (plan_id, transaction_id, payee, number, months, amount), fees to record (plan_id, transaction_id, payee, amount) and limit_used. Paying is ordinary POST .../transactions |
| GET | /v1/books/{book_id}/accounts/{id}/bill |
The first of .../bills (the one due now), or null |
| GET, POST | /v1/books/{book_id}/categories |
|
| PATCH, DELETE | /v1/books/{book_id}/categories/{id} |
DELETE archives a used category |
| GET, POST | /v1/books/{book_id}/transactions |
Filters: account_id (with running balance), category_id (with sub-categories), from, to, type, q; newest first by date then time; pages by after. occurred_time: HH:MM, 00:00 if left out |
| GET | /v1/books/{book_id}/transactions/suggest |
type (expense/income), amount → {account_id, category_id, payee} or null: from this user’s own transactions of the type, within 10 % of the amount, in the last 90 days, what makes up at least 2 of the last 3. The web app fills a new transaction’s untouched fields with it |
| GET, PUT, DELETE | /v1/books/{book_id}/transactions/{id} |
PUT without occurred_time keeps the saved time |
| GET | /v1/books/{book_id}/transactions/{id}/history |
Every saved version |
| GET, POST | /v1/books/{book_id}/contacts |
With each person’s currency (of their own account, which has their id) and balances. POST takes currency (default: the book’s) and adds their account |
| PATCH, DELETE | /v1/books/{book_id}/contacts/{id} |
Renaming or archiving a person does the same to their account. DELETE deletes their account too when nothing is recorded; otherwise, or when a lent or loan account links them, it archives both |
| GET, POST | /v1/books/{book_id}/budgets |
With current progress |
| PATCH, DELETE | /v1/books/{book_id}/budgets/{id} |
|
| GET, POST | /v1/books/{book_id}/installments |
Credit card purchases paid in installments, with installment, billed and left_to_bill. POST: id, transaction_id (an expense on a card with a statement day), months (2 to 60), optional converted_on (the purchase’s date), first_statement (the statement the conversion date falls in), fee, fee_split, counts_toward_limit (true). 409 installment_exists for a purchase that has one |
| PATCH, DELETE | /v1/books/{book_id}/installments/{id} |
PATCH takes version and any of the POST fields, and ended_on to end it early (null: not ended) |
Account details by kind
All optional. One sent for a kind it doesn’t belong to is 422 invalid; changing an account’s kind clears the
ones that stop applying.
| Field | Kinds | Value |
|---|---|---|
note |
all | Text, up to 500 characters |
credit_limit, annual_fee |
credit_card |
Minor units of the account’s currency, 0 or more |
statement_day |
credit_card |
Day of the month, 1 to 31 |
payment_due_day |
credit_card, loan |
Day of the month, 1 to 31: a card’s payment due day, a loan’s payment day |
installment |
loan |
The monthly payment, minor units, above 0 |
installment_fee |
loan |
A one-off fee, minor units, 0 or more; charged once, with the first payment |
interest_rate_bp |
savings, credit_card, loan, lent |
Yearly rate in hundredths of a percent (550 is 5.5 %), 0 to 100000. Information only: it never changes a balance |
Sharing a book
| Method | Path | Notes |
|---|---|---|
| GET | /v1/books/{book_id}/members |
Every member: name (username or “Deleted user”), display_name, role, pending, me |
| POST | /v1/books/{book_id}/members |
Owners: username, role (default editor) → the member list. 409 cannot_invite for an unknown username and for someone already in the book, alike; rate limited |
| PATCH | /v1/books/{book_id}/members/{user_id} |
Owners: role. 409 owner_required for the last owner |
| DELETE | /v1/books/{book_id}/members/{user_id} |
Owners remove anyone or cancel an invite; anyone leaves with their own id. 409 owner_required for the last owner |
| GET | /v1/me/book-invites |
Invites I haven’t answered: book, role, who invited me |
| POST | /v1/me/book-invites/{book_id}/accept |
Become a member |
| DELETE | /v1/me/book-invites/{book_id} |
Decline; the invite goes away, nobody is told |
Source: waterflow/backend/app/api/routes/v1/. This page is the list: a new route comes with a paired docs PR that
adds it here.