This app is a server-rendered web application first. There is no separate "API server" — the same HTTP routes that render HTML also answer JSON, and a personal API token lets your own scripts reach a safe subset of them.
This document is the contract for that surface: how to authenticate, what a
token may and may not touch, how responses are shaped, and the machine-readable
endpoints worth scripting against. It is generated from the code in
src/app.ts, src/auth/tokens.ts,
and src/http/server.ts — where those disagree with
this file, the code wins; please open an issue.
- Model at a glance
- Authentication
- Rate limiting
- Requesting JSON
- Idempotency
- Response & error shapes
- Machine-readable endpoints
- Worked examples
- Full route reference
- Security notes
| Transport | Plain HTTP/1.1. Behind a reverse proxy in production (TLS terminates there). |
| Base URL | Your deployment's origin — http://localhost:8080 in dev, e.g. https://pathayam.example.com in prod. |
| Two ways in | A session cookie (pathayam_session, set by signing in through a browser) or a bearer token (Authorization: Bearer bgt_…, for scripts). |
| Token precedence | A bearer token is honoured only when there is no session cookie on the request. Send one or the other, not both. |
| Identity | A token acts as the member who minted it. Every write is attributed to that member and names the token in the event log (F30.5). |
| Format | Send Accept: application/json to get JSON back from any route; otherwise you get HTML (or a redirect). |
| State | Every mutation is an append-only event (R37) — so anything a token does is undoable and shows up in "explain this number". |
There is no OAuth, no API versioning path prefix, and no separate hostname. The API is the app, narrowed by a token.
Tokens are created from the web UI only — never over the API (a token that
could mint tokens turns one leak into permanent access, F30.6). Sign in as a
member and open Settings → API tokens (/tokens):
- Give the token a name (e.g.
nightly-export) — this is what appears in the event log next to everything it does. - Choose a scope: read or read-write.
- Choose an expiry: 90 days, a year, or never. (The server takes a whole number of days from 1 to 3650, or none; anything else is refused with 422.)
The secret is shown exactly once, at creation, and is never retrievable again — only a SHA-256 hash is stored, so the database cannot give it back even to the server (F30.4). Copy it immediately. If you lose it, revoke it and mint a new one.
A token looks like:
bgt_Xa3f9K2pLq7wYt0zR8sN1vB4cD6eF5gH2jK8mP0qRsT
The bgt_ prefix is a human hint for spotting it in a config file; the entropy
is 32 random bytes, base64url-encoded.
Send it in the Authorization header on every request:
curl -sS https://pathayam.example.com/export.json \
-H "Authorization: Bearer bgt_Xa3f9K2pLq7wYt0zR8sN1vB4cD6eF5gH2jK8mP0qRsT" \
-H "Accept: application/json"Revoked and expired tokens are rejected exactly like wrong ones — the server
never tells you which it was. A token also stops working when its member is
removed from the household, as their sessions do, and none can be minted while
viewing as another member. Each accepted request updates the token's
last_used_at, which is how a household spots a token they forgot about
(visible back in Settings → API tokens).
| Scope | GET (reads) | POST (writes) |
|---|---|---|
read |
✅ | ❌ 403 { "error": "That token is read-only." } |
read-write |
✅ | ✅ |
Scope is fixed at mint time. To change it, revoke and re-mint.
Every unsafe method (POST, and anything else that is not a read) must either
carry a bearer token or come from this app's own pages, proved by an
Origin header — otherwise it is refused with 403. It is what stops another
site making a browser submit a form on somebody's behalf with their cookie
attached.
A token-authenticated call is exempt, which is the case that matters here:
your scripts send Authorization: Bearer … and no cookie, so there is no
session for anybody to ride. Nothing extra to set.
A browser-side script using the session cookie instead does need to send an
Origin belonging to the deployment — which fetch from a page of the app does
on its own.
Regardless of scope, a token is refused (403) on these path prefixes (F30.6,
enforced as a deny-list in tokenMayReach):
| Prefix | Why it's blocked |
|---|---|
/tokens |
A token must not mint or revoke tokens — including itself. |
/members |
The household allow-list — /members/invite today. A token must not change who is allowed in. A settings sub-page for the same thing is denied alongside it, so it is covered before it exists. |
/impersonate |
Impersonation is a session-only, logged human action (R38.12). |
/auth |
Sign-in, SSO, and the dev-bypass state. |
/signout |
Nothing for a token to sign out of. |
/settings/password |
A password is a way in that does not need the token. A token that could set one would outlive its own revocation — and setting it signs the member's browsers out. |
/sessions |
Signing the member's devices out is a human decision, not a script's. |
/gmail/connect, /gmail/callback, /gmail/disconnect |
Linking or unlinking a mailbox changes what can reach the household, not what the budget says. /gmail/fetch stays reachable. |
// 403 on any forbidden prefix
{
"error": "An API token cannot reach this. Tokens cannot sign in, impersonate, mint other tokens, or change who is allowed in."
}Each token gets a fixed window of 120 requests per 60 seconds, counted independently of any browser session (F30.7) so a runaway script cannot lock the household out of their own budget.
When you exceed it:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
{ "error": "That token is going too fast. Try again shortly." }The Retry-After header is the whole number of seconds until the window resets.
The limiter is in-process (the deployment is a single process on one box), so it
resets if the server restarts.
Content negotiation is by the Accept header — nothing else. If it contains
application/json, you get JSON; otherwise you get HTML.
# JSON
curl -H "Accept: application/json" .../move -X POST ...
# HTML (default browser behaviour)
curl .../move -X POST ...This applies to every route, not just the machine endpoints. A POST you'd
normally make from a form returns, in JSON mode, the redirect target and a
human message instead of a 303 (see below).
Any mutation (POST) accepts an Idempotency-Key header (R36). Replaying a
request with the same key, member, method, path and payload returns the original
outcome instead of performing the action twice — the safe way to retry after a
dropped connection.
curl -sS .../add -X POST \
-H "Authorization: Bearer bgt_…" \
-H "Accept: application/json" \
-H "Idempotency-Key: 6f1c2e9a-2b7d-4c11-9f3e-8a5b1d0c7e42" \
--data 'account_id=…&amount=-45000&payee=Chai&category_id=…'- Same key, same payload → the stored result is replayed; nothing new happens.
- Same key, different payload → the request is rejected as a conflict with
the status code recorded for the original (an
IdempotencyConflictsurfaces as an HTTP error). Use a fresh key for a genuinely new action.
Generate a UUID per logical action. Reusing a key across unrelated writes will mask the second one.
Routes that ask a question before writing — the "Already reconciled" confirmation on an edit, delete or undo, the preview before applying a rule to history — answer the question afresh; only the write behind it is keyed. A repeated reconcile shows the same checkpoint again rather than recording another.
Successful mutation (JSON mode) — a 200 with the redirect the browser
would have followed and a message:
{ "redirect": "/accounts/9f2…", "message": "Saved." }Redirect (JSON mode) — the same idea for plain redirects:
{ "redirect": "/review" }Reads — routes that render a page return HTML even in JSON mode unless the route explicitly emits JSON. The endpoints under Machine-readable endpoints are the ones designed to be consumed as data.
Errors — in JSON mode every error is { "error": "…" } with the right
status code (without Accept: application/json the same message comes back as
an HTML page):
| Origin | Status |
|---|---|
| The auth / token layer | 401, 403, 429 |
| A handler's answer | 400 malformed input · 404 an id, in the URL or a form field, that does not exist or is not yours · 409 idempotency conflict · 422 the app refuses what was asked, and says why |
| An unexpected server fault | 500, Something went wrong on the server. Nothing you typed has been lost. (details go to the structured log, never the response) |
So a script should branch on the status code and show .error to a
person. A financial value is never placed in a log line or an error body (S7).
Common statuses: 200 OK · 204 No Content · 303 See Other (HTML redirect) ·
400 bad input · 401 not authenticated · 403 forbidden / read-only / out of
a token's reach · 404 not found · 405 method not allowed · 409
idempotency conflict · 422 refused, with the reason · 429 rate-limited · 500 server fault · 503 health
check failing (from /healthz).
These are the routes built to be scripted. All are GET, all are reachable by a
read token, and all require authentication.
A liveness probe for external monitoring (F27.3). Returns 200 while the
instance can serve — the database answers — and 503 when it cannot. Nothing
recorded in the past (a failed request, a missed backup) changes the status
code.
Signed out, it says nothing else: the full diagnosis names counts, backup gaps and build details that are nobody's business but the household's.
curl -sS https://pathayam.example.com/healthz{ "status": "ok", "serving": true, "version": "0.1.0" }With a session or a read token it adds every check:
curl -sS -H "Authorization: Bearer $TOKEN" https://pathayam.example.com/healthz{
"status": "healthy",
"serving": true,
"version": "0.1.0",
"checks": [
{ "group": "Backups", "name": "Last backup", "state": "healthy", "reason": "…" },
{ "group": "Backups", "name": "Restore verification", "state": "healthy", "reason": "…" },
{ "group": "Prices", "name": "Price feed", "state": "degraded", "reason": "…" }
]
}Authenticated, status is the worst of the individual state values
(healthy / degraded / failed) and is diagnostic only: serving decides
the HTTP status.
The entire budget as a single JSON document (F15) — the same export used for backups. Served as a download.
curl -sS https://pathayam.example.com/export.json \
-H "Authorization: Bearer bgt_…" -H "Accept: application/json" \
-o budget-$(date +%F).jsonNote: the export deliberately excludes statement identity (PAN / DOB / mobile) and Gmail refresh tokens — that data is never exported and never travels (F15).
Every transaction as CSV, for a spreadsheet.
curl -sS https://pathayam.example.com/export.csv \
-H "Authorization: Bearer bgt_…" -o transactions.csvAvailable when the assets feature is enabled. One shape each (F19.13):
| Endpoint | Contents |
|---|---|
GET /portfolio/holdings.csv |
Current holdings — units, average cost, price, value, gain. |
GET /portfolio/lots.csv |
Individual FIFO lots with acquisition dates and costs. |
GET /portfolio/prices.csv |
Price history per instrument. |
GET /net-worth.csv |
The dated net-worth history. |
for f in holdings lots prices; do
curl -sS "https://pathayam.example.com/portfolio/$f.csv" \
-H "Authorization: Bearer bgt_…" -o "$f.csv"
done
curl -sS https://pathayam.example.com/net-worth.csv \
-H "Authorization: Bearer bgt_…" -o net-worth.csvEach download's filename carries the current IST date, e.g.
holdings-2026-08-28.csv.
A nightly backup, verifying it isn't empty:
#!/usr/bin/env bash
set -euo pipefail
TOKEN="bgt_…" # a read-scoped token
BASE="https://pathayam.example.com"
# Fail fast if the app is unhealthy.
curl -fsS "$BASE/healthz" >/dev/null
out="budget-$(date +%F).json"
curl -fsS "$BASE/export.json" \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
-o "$out"
# A trivial sanity check before trusting the file.
bytes=$(wc -c < "$out")
[ "$bytes" -gt 1000 ] || { echo "export suspiciously small ($bytes bytes)"; exit 1; }
echo "saved $out ($bytes bytes)"Adding a transaction safely (read-write token, idempotent):
KEY=$(uuidgen)
curl -fsS "$BASE/add" -X POST \
-H "Authorization: Bearer $RW_TOKEN" \
-H "Accept: application/json" \
-H "Idempotency-Key: $KEY" \
--data-urlencode "account_id=$ACCOUNT" \
--data-urlencode "amount=-45000" \
--data-urlencode "payee=Chai" \
--data-urlencode "category_id=$CATEGORY"
# → { "redirect": "/accounts/…", "message": "Saved." }
# Retrying with the same $KEY is a no-op.Amounts are integer paise (-45000 = −₹450.00). See
docs/02-* for the money model.
Every route below is served by src/app.ts. "Token" shows
whether a read token (R), a read-write token (W), or no token (—,
session-only, because the path is in the forbidden list) can reach it. All
routes require authentication except /signin, /auth/*, and /healthz.
Legend: R = any token · W = read-write token only · — = session only (out of a token's reach).
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | / |
R | The month budget grid. |
| POST | /assign |
W | Assign to a category. |
| GET/POST | /move |
R/W | Move between envelopes. |
| GET/POST | /hold |
R/W | Hold income for next month. |
| GET/POST | /auto-assign |
R/W | Fund-to-target, with preview — for one budget (?budget=, else the last one viewed), from that budget's Ready to Assign. |
| GET | /overview |
R | Runway, due-soon bills, the month at a glance. |
| POST | /budgets/personal |
W | Create your own personal budget, if you do not have one (15 §2). |
| GET | /more |
R | The hub of everything not in the primary nav. |
| POST | /copy-last-month |
W | Copy last month's assignments into this one. |
| GET | /explain/ready-to-assign |
R | Why RTA is what it is. |
| GET | /explain/category/:id |
R | Explain a category's number. |
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | /accounts |
R | All accounts and balances. |
| GET/POST | /accounts/new |
R/W | Create an account. |
| GET | /accounts/:id |
R | One account's register. |
| POST | /accounts/:id/edit · /accounts/:id/close · /accounts/:id/reopen |
W | Rename, close (never delete) and reopen an account. |
| GET/POST | /accounts/:id/statement |
R/W | Record a card statement: amount, date, due date, minimum. |
| GET | /accounts/:id/cards |
R | Cards on one account, including add-ons. |
| POST | /accounts/:id/cards/:cardId/close |
W | Close an add-on card. |
| GET | /cards |
R | Every credit card in due-date order, with what is unfunded. |
| GET/POST | /accounts/:id/reconcile |
R/W | Reconcile to a statement balance. |
| GET/POST | /add |
R/W | Create a transaction, with its envelope lines (split_category_N / split_amount_N; the first carries no amount and takes the remainder). |
| POST | /transfer |
W | Transfer between accounts. |
| GET | /transaction/:id |
R | Transaction detail, splits, history. |
| POST | /transaction/:id |
W | Edit a transaction, including its envelope lines (same fields as /add). A legacy category_id is still read when no lines are sent; an empty one on an already-split transaction means "not mentioned", not "file it nowhere". |
| POST | /transaction/:id/delete |
W | Delete (undoable). |
| POST | /transaction/:id/categorise |
W | File one transaction into an envelope. Also the learning signal (B100). |
| POST | /transaction/:id/settled |
W | Mark a reimbursable transaction as repaid. |
| POST | /transaction/:id/attach |
W | Attach a receipt. |
| GET | /attachment/:id |
R | Fetch a receipt's bytes. |
| POST | /attachment/:id/delete |
W | Remove a receipt. |
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | /review |
R | The review queue. |
| POST | /review/approve · /review/reject · /review/merge |
W | Act on staged items. |
| GET/POST | /import |
R/W | CSV paste import. |
| POST | /import/pdf |
W | Statement-PDF upload. |
| POST | /import/map |
W | Save a column mapping. |
| POST | /import/undo |
W | Undo an import batch. |
| POST | /import/profiles/:id/delete |
W | Delete a saved mapping. |
| GET | /rules |
R | Rules list. |
| POST | /rules/test · /rules/new · /rules/:id/apply · /rules/:id/delete · /rules/confirm · /rules/dismiss |
W | Manage rules. |
| GET | /payees |
R | Payees. |
| POST | /payees/merge |
W | Merge two payees. |
| GET | /household |
R | What each member has committed to the shared budget this month. |
| GET | /members/:id/remove |
R | What removing a member does, and how the balance between you can end. |
| POST | /members/:id/remove |
W | Remove a member, settling what is outstanding first (15 §6A). |
| POST | /household/pick-up |
W | Commit more, taking on what the household is behind by. |
| POST | /household/call-it-even |
W | Close part of a balance by agreement; it becomes spending on the giving side. |
| GET | /categories |
R | Categories, for the budget being viewed (?budget=). |
| POST | /groups/new · /groups/:id/rename · /groups/:id/delete |
W | Add, rename or remove a category group. A group must be empty to be deleted. |
| POST | /categories/new · /categories/:id/rename · /categories/:id/hide · /categories/:id/delete |
W | Manage categories. |
| GET/POST | /tax |
W | Income tax estimate for the signed-in member and one financial year (fy). POST saves gross income and deductions. Refuses a year whose rates the app does not have. |
| POST | /categories/:id/merge |
W | Merge this category into winner_id: assignments are summed month by month, history and targets move across, and this one is deleted. Refuses a payment category and a merge across budgets. |
| POST | /categories/:id/target |
W | Set or clear a category's target: amount, kind (monthly or refill; omitted keeps a stored refill) and optional target_date (makes it by-date). |
| POST | /categories/:id/reorder · /groups/:id/reorder |
W | Move a category or group up or down. |
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | /schedules |
R | Recurring items & cashflow calendar. |
| POST | /schedules/new · /schedules/confirm · /schedules/dismiss · /schedules/:id/paid · /schedules/:id/skip |
W | Manage schedules. A schedule may be money in or money out; /schedules/new takes envelope lines like /add. recurrence must be one of the eight the app knows — an unrecognised value is refused rather than silently treated as monthly. With monthly-nth-weekday, recurrence_ordinal (1–4, or −1 for last) and recurrence_weekday (0 = Sunday) say which one, and next_due is moved to the first such day on or after it. |
| POST | /schedules/:id/edit · /schedules/:id/delete |
W | Change or remove a schedule, including its envelope lines (split_category_N / split_amount_N; the first carries no amount and takes the remainder). A new amount with no line fields re-files the stored lines the same way, or is refused when the other lines leave nothing for the first. Removing leaves everything it already recorded. |
| GET | /goals |
R | Savings goals. |
| POST | /goals/new · /goals/:id/complete · /goals/:id/edit · /goals/:id/delete |
W | Manage goals. |
| GET | /loans · /loans/:id |
R | Loans and one loan's detail. |
| GET/POST | /loans/new |
R/W | Create a loan. |
| GET/POST | /loans/what-if |
R/W | Prepayment / rate what-if. |
| POST | /loans/:id/disburse |
W | Record a tranche drawdown. |
| GET/POST | /loans/:id/pay |
R/W | Record an instalment. |
| GET/POST | /loans/:id/prepay |
R/W | Record a prepayment. |
| GET/POST | /loans/:id/rate |
R/W | Record a rate reset. |
| POST | /loans/:id/holder |
W | Change whose loan it is and whether it is private (H2, H2.2). |
| POST | /loans/:id/close |
W | File away a paid-off loan (R21). |
| POST | /loans/:id/settle |
W | Settle a loan early, recording the lender's foreclosure charge (R19.5). |
| POST | /transaction/:id/convert-to-emi |
W | Convert a card purchase to an instalment plan (06 §7.4, F8.5). |
| GET | /loans/:id/statement |
R | One loan's statement view. |
| GET | /loans/:id/schedule.csv |
R | Amortisation schedule as CSV. |
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | /family · /family/:id |
R | Lent/borrowed and one arrangement. |
| POST | /family/new · /family/:id/advance · /family/:id/repayment · /family/:id/write-off · /family/:id/close |
W | Manage family loans. |
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | /portfolio |
R | Holdings, XIRR, other assets. |
| GET | /portfolio/:id |
R | One instrument. |
| GET/POST | /portfolio/add |
R/W | Add a holding. ?instrument=<id> (where "Choose" on a searched scheme lands) buys that instrument; the POST takes instrument_id for it. |
| GET/POST | /portfolio/:id/sell |
R/W | Record a sale. |
| GET/POST | /portfolio/cas |
R/W | Import a CDSL CAS. |
| POST | /portfolio/cas/confirm |
W | Confirm a parsed CAS. |
| GET | /portfolio/allocation |
R | Allocation by class/region/currency. |
| POST | /portfolio/instrument/:id/classify |
W | Set an instrument's class. |
| POST | /portfolio/refresh |
W | Refresh prices. |
| GET | /net-worth |
R | Net-worth decomposition & history. |
| GET | /fire |
R | Financial-independence projection from trailing spending. |
| POST | /net-worth/snapshot |
W | Take a dated snapshot. |
| GET/POST | /portfolio/asset/new |
R/W | Add a hand-valued asset. |
| GET/POST | /portfolio/asset/:id/add |
W | Buy more of a hand-valued asset: records the payment leaving an account and the new stated value. |
| GET/POST | /portfolio/asset/:id/dispose |
W | Record that a hand-valued asset is gone, and optionally the money arriving. Values it at zero on the date and closes the account; the dated history stays. |
| GET/POST | /portfolio/asset/:id/revalue |
R/W | Say what one asset is worth today. |
| GET/POST | /portfolio/valuations |
R/W | Update every hand-valued asset in one sitting. |
| GET/POST | /portfolio/:id/price |
R/W | Record a price for one instrument. |
| GET/POST | /portfolio/:id/split |
R/W | Record a stock split or bonus issue. It applies to every open holding of the instrument; the same action on the same date is refused a second time. |
| GET/POST | /portfolio/:id/merge |
R/W | Record a merger or scheme amalgamation (R28). Cost and purchase dates carry forward, so nothing is realised. |
| GET | /portfolio/holdings.csv · /portfolio/lots.csv · /portfolio/prices.csv · /net-worth.csv |
R | CSV exports. |
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | /months · /months/:month/close |
R | Month-close ritual & history. |
| POST | /months/:month/close · /months/:month/reopen |
W | Close / reopen a month. |
| GET | /reports |
R | Income vs spend, trends, loan interest. |
| GET | /query · /search |
R | Filterable table. |
| GET | /query.csv |
R | Query results as CSV. |
| Method | Path | Token | Purpose |
|---|---|---|---|
| GET | /settings |
R | Household settings. |
| POST | /settings/theme · /settings/learning · /settings/overspend-model · /settings/identity · /settings/digest · /settings/password |
W | Change settings. |
| GET | /activity |
R | Every change ever made, with its undo. |
| POST | /activity/:id/undo |
W | Undo one recorded event (30 days, R37.8). |
| GET | /health |
R | Health dashboard (HTML). |
| POST | /health/backup · /health/verify |
W | Run a backup / verify a restore. |
| POST | /gmail/disconnect · /gmail/fetch |
W | Manage Gmail ingestion. |
| GET | /export.json · /export.csv |
R | Full data exports. |
| GET | /healthz |
R (public) | Machine health probe. |
These return 403 to any token — use a browser session.
| Method | Path | Why |
|---|---|---|
| GET/POST | /tokens, /tokens/:id/revoke |
Minting/revoking tokens (F30.6). |
| POST | /members/invite |
The household allow-list. |
| POST | /impersonate/start · /impersonate/writes · /impersonate/exit |
Impersonation (R38.12). |
| POST | /sessions/revoke |
Session management. |
| GET | /auth/google · /auth/google/callback · /auth/oidc · /auth/oidc/callback · /gmail/connect · /gmail/callback |
OAuth and OIDC flows (interactive). |
| POST | /auth/dev · /signout |
Sign-in / sign-out. |
| GET/POST | /auth/first-run |
The first password on a household with no members. 404 once any member exists. |
| POST | /auth/password |
Password sign-in (F1.6). 404 unless LOCAL_LOGIN is set or a password already exists. |
| GET/POST | /settings/password |
Set or change your own password. Changing one requires the current password. |
This table is a snapshot; the authoritative list is the router in
src/app.tsand the deny-list insrc/auth/tokens.ts.
- The secret is shown once. Store it in a secrets manager or an env var, never in the repo. If it leaks, revoke it in Settings → API tokens; revocation is immediate.
- Prefer
readscope. Most automation (backups, dashboards, monitoring) never needs to write. A read token that leaks cannot change your budget. - Set an expiry for tokens tied to a specific task.
- A token is narrower than a session by design — it cannot authenticate a human, impersonate, mint tokens, or change the household allow-list, whatever its scope. This is a deny-list, so new routes are unreachable by tokens until proven safe, not the reverse.
- Everything a token does is logged and undoable — attributed to the member, naming the token (F30.5), as an append-only event (R37).
- Never send identity or credentials over the API. Statement identity and Gmail tokens are not exportable and not returned by any endpoint.