The API
openbooks-api is a Rust HTTP API for double-entry bookkeeping. It’s built for a
family or club that wants a real ledger — money in, money out, moved between
accounts — without anyone involved needing to know what “double-entry” means.
The stack is axum on top of Postgres, accessed
through sqlx. The router and handlers live in
openbooks-api/src/lib.rs; src/main.rs is a thin binary that reads config, runs
migrations, and calls into it. An MCP server is mounted alongside the HTTP routes so an
AI assistant can read the books and record transactions the same way the web UI
does; see MCP server.
Running it
Section titled “Running it”Use just from the repo root rather than driving Docker or cargo by hand:
just run # starts postgres, api, and web in the backgroundjust seed # loads deterministic sample data (2025 through mid-2026)just smoke # end-to-end ledger and report check (needs jq)just stop # stop everythingjust run waits for Postgres to be healthy before running migrations. Migrations
run automatically on API startup (sqlx::migrate!().run(&pool) in
openbooks-api/src/main.rs) — there’s no separate migration step to remember.
If you want to run the API directly instead of through just:
docker compose up -d # postgres on host port 38083cargo run # runs migrations, then listens on :38081./smoke.sh # needs jqLogs go to .dev/api.log when run through just; nothing prints to your
terminal since the servers run in the background. just status tells you
what’s actually up.
Base URL and configuration
Section titled “Base URL and configuration”The API listens on 0.0.0.0:38081 by default. These environment variables
control it:
| Variable | Default | Purpose | Read in |
|---|---|---|---|
DATABASE_URL |
postgres://openbooks:openbooks@localhost:38083/openbooks |
Postgres connection string | src/main.rs |
BIND_ADDR |
0.0.0.0:38081 |
address the HTTP server binds to | src/main.rs |
WEB_ORIGIN |
http://localhost:38080 |
the only origin CORS allows to send credentialed requests | src/lib.rs |
API_ORIGIN |
http://localhost:38081 |
this API’s own canonical URL, used as the token audience and in the WWW-Authenticate challenge |
src/auth/mod.rs |
SESSION_TTL_DAYS |
30 |
how long a fully authenticated session lasts from creation | src/auth/session.rs |
Every example in this section assumes the default, so requests target
http://localhost:38081.
Authentication is required; there’s no RBAC
Section titled “Authentication is required; there’s no RBAC”Every route except GET /health needs a session cookie or a bearer token —
see Authentication for the credential types,
the /auth/* routes, login backoff, and what phase 1 doesn’t do yet (no
second factor, no OAuth). There’s no per-user permission model on top of
that: any authenticated user can read and write the whole ledger. That’s
deliberate, not an oversight — see the project’s root CLAUDE.md.
CORS is an explicit allowlist
Section titled “CORS is an explicit allowlist”The CORS layer allows exactly one origin — WEB_ORIGIN — with credentials,
and only the methods the API actually uses:
CorsLayer::new() .allow_origin([origin]) .allow_credentials(true) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])The origin is passed as a one-element array rather than a bare value: a bare
HeaderValue becomes AllowOrigin::exact, which echoes the configured
origin on every response without checking the request’s Origin header at
all — not actually an allowlist. The array form is AllowOrigin::list,
which does filter, so a request from an unrecognized Origin gets no
Access-Control-Allow-Origin header back. CORS governs whether a browser
lets script read a cross-origin response, not whether the request gets
sent — SameSite=Lax on the session cookie is what actually protects
state-changing routes from another site.
Admin commands
Section titled “Admin commands”User accounts are created and managed on the API binary directly, never over
HTTP — anyone who can run these already has DATABASE_URL and could reach
the database directly, so there’s no privileged endpoint to protect with a
role this system deliberately doesn’t have. Verbatim from admin::USAGE:
openbooks-api serveopenbooks-api user add <email> [--password -] create a user, print a passwordopenbooks-api user list email, passkey count, last seenopenbooks-api user passwd <email> [--password -] reset a passwordopenbooks-api user rm <email> delete, cascading sessions/tokensopenbooks-api mint-token <email> [--label <name>] a token for scripts and clientsuser add and user passwd print a generated password once — it’s not
stored anywhere and can’t be shown again, so write it down or pipe a
password in with --password - (reads one line from stdin). user passwd
and user rm (via the cascading foreign keys in 0004_auth.sql) end every
existing session and revoke every token for that user, the same as a
self-service password change through POST /auth/password. user rm also
nulls transactions.created_by for that user’s rows — attribution doesn’t
cascade, so the transactions stay in the ledger and only the name goes.
From the repo root, just wraps these so you don’t need DATABASE_URL set
by hand:
just user-add you@example.com # user addjust users # user listjust mint-token you@example.com # mint-tokenSee Running the stack for the full recipe list.
Error shape
Section titled “Error shape”Handler errors come back as JSON with a single error field:
{ "error": "needs 2+ entries summing to zero (got 1 entries summing to 4500)" }This is produced by AppError’s IntoResponse impl in openbooks-api/src/lib.rs.
Two things map to 400 Bad Request:
- The API’s own validation (missing name, empty description, unbalanced entries).
- A Postgres error whose code is
P0001(aplpgsql raise exception, which is how the balance-check trigger reports a violation) or starts with23(a constraint violation, such as an unknownaccount_id).
Everything else — a real database or server fault — comes back as
500 Internal Server Error with error set to a fixed sentence, “something
went wrong on the server”, and nothing more specific: no table or column
names, no driver text. The detail is logged server-side (.dev/api.log), not
returned — the web app renders error verbatim, and a volunteer treasurer
should never see a database’s internals.