talea

HTTP API reference

The complete wire protocol for a running talea server. Every claim here is generated-from or checked-against the code; the same contract is machine-readable at GET /openapi.json and browsable at GET /docs (Swagger UI).

For why the API behaves this way, see Architecture & design. For a guided first session, see the tutorial.

Conventions

Authentication

When any token is configured (TALEA_API_TOKEN, TALEA_TOKENS_FILE, or both), every /v1 route requires:

Authorization: Bearer <token>

The scheme is case-insensitive per RFC 7235 (bearer, BEARER both work); the token is compared in constant time. Missing or wrong token → 401 {"error":"unauthorized"}. With neither variable set the server runs in open mode (dev) and the header is ignored. /health, /docs, and /openapi.json are always open.

Scoped tokens

TALEA_TOKENS_FILE points at a TOML file that confines each token to a set of books with ro or rw access:

[tokens.payments]
token = "s3cret-1"
books = ["payments"]   # exact book names, or ["*"] for all books
access = "rw"          # "ro" = read-only

A valid token used outside its scope answers 403 {"error":"forbidden","book":"..."} — distinct from 401 (bad token). The book is checked where it appears in the request: the path for reads and SSE, the request body for POST /v1/accounts and POST /v1/transactions. These 403s only echo a name the caller supplied, so they reveal nothing about what exists. GET /v1/transactions/{tx_id} is the exception: the book is only known after the load, so an out-of-scope transaction answers 404 exactly like an unknown id — a 403 there would confirm the id exists and name a book the token cannot see. Registering assets requires an rw token scoped ["*"] (the registry is global; the 403 carries "book":"*"). TALEA_API_TOKEN remains equivalent to an unnamed all-books rw entry.

Routes

POST /v1/assets — register an asset

Idempotent on id: re-registering an identical definition is a no-op; a different definition for the same id is 409 already_exists.

Request (AssetDraft):

{
  "id": "USD",
  "class": "fiat",
  "network": null,
  "native_id": null,
  "precision": 2,
  "name": "US Dollar"
}

Responses: 204 on success · 400 invalid_draft · 401 · 403 forbidden (requires an rw token scoped ["*"]) · 409 already_exists · 415 (content-type is not application/json).

POST /v1/accounts — open an account

Idempotent on book + path.

Request (AccountDraft):

{
  "book": "onramp",
  "path": "treasury:btc",
  "asset": "BTC",
  "kind": "asset",
  "normal_side": "debit",
  "min_balance": 0
}

Responses: 204 · 400 invalid_draft · 401 · 403 forbidden (token scope does not cover the draft’s book) · 404 unknown_asset · 409 already_exists · 415.

POST /v1/transactions — post a transaction

Idempotent on idempotency_key (unique per book): replaying a key returns the original commit with "deduplicated": true and never double-posts.

Request (TransactionDraft):

{
  "book": "onramp",
  "idempotency_key": "deposit-7f3a",
  "postings": [
    {"account": "cash",     "amount": {"minor": 100000, "asset": "USD"}, "direction": "debit"},
    {"account": "deposits", "amount": {"minor": 100000, "asset": "USD"}, "direction": "credit"}
  ],
  "external_refs": [{"kind": "btc_txid", "value": "4a5e..."}],
  "metadata": {"channel": "wire"},
  "occurred_at": "2026-06-04T12:00:00Z"
}

Response 200 (Posted):

{
  "tx_id": "0190c8a4-...",
  "seq": 3,
  "at": "2026-06-04T12:00:00.123456Z",
  "deduplicated": false
}

Other responses: 400 unbalanced | invalid_amount | invalid_draft · 401 · 403 forbidden (token scope does not cover the draft’s book) · 404 unknown_account · 409 constraint_violation · 415 · 429 overloaded (+ Retry-After: 1 — the per-book write queue is full, or the DB pool is saturated; retry with the same idempotency key).

POST /v1/transactions/batch — post multiple transactions

Submit an array of TransactionDraft objects in one request and receive one positional result per draft. Drafts may span different books. An empty array is accepted and returns 200 [] immediately.

Request body: JSON array of TransactionDraft (the same shape as the single-transaction route):

[
  {
    "book": "onramp",
    "idempotency_key": "deposit-7f3a",
    "postings": [
      {"account": "cash",     "amount": {"minor": 100000, "asset": "USD"}, "direction": "debit"},
      {"account": "deposits", "amount": {"minor": 100000, "asset": "USD"}, "direction": "credit"}
    ]
  },
  {
    "book": "fees",
    "idempotency_key": "fee-7f3a",
    "postings": [
      {"account": "revenue", "amount": {"minor": 200, "asset": "USD"}, "direction": "credit"},
      {"account": "clearing", "amount": {"minor": 200, "asset": "USD"}, "direction": "debit"}
    ]
  }
]

Response 200: a JSON array of the same length, each item either the Posted object (on success) or the standard ApiError envelope (on per-draft failure). The shapes are byte-identical to the single-transaction route’s 200 and error responses. Per-draft failures do not affect other slots.

[
  {"tx_id": "0190c8a4-...", "seq": 3, "at": "2026-06-04T12:00:00.123456Z", "deduplicated": false},
  {"error": "unbalanced", "asset": "USD", "debit": 200, "credit": 0}
]

Positional contractout[i] corresponds to drafts[i]. A failure in one slot (unbalanced, unknown account, constraint violation, …) sets that slot’s error envelope; it has no effect on any other slot.

Scoped-token 403s in-slot — when a scoped token does not cover the book named in a draft, that slot receives {"error":"forbidden","book":"..."} in the response array (not a whole-request 403). Slots whose books are in scope proceed normally. All other slot-level errors (400, 404, 409) work the same way.

Whole-request errors (not per-slot) follow the same contract as POST /v1/transactions: 401 for a bad or missing token, 415 for wrong content type, and 400 invalid_draft when the body is malformed or the array length exceeds TALEA_HTTP_BATCH_MAX (default 500, minimum 1). The cap is a safety valve against request amplification; axum’s 2 MiB body limit is the memory ceiling.

Idempotency — each draft’s idempotency key deduplicates independently, whether against prior commits or other drafts in the same batch. Two drafts with the same key both resolve to the original Posted (with deduplicated: true). Retrying the whole batch after a whole-request failure is always safe.

Other whole-request responses: 408 timeout · 503 overloaded (admission shed; carries Retry-After: 1).

GET /v1/books/{book}/accounts/{path}/balance?as_of=

Current balance, or point-in-time when as_of (RFC 3339) is given — replayed by commit time.

Response 200 (BalanceView):

{
  "account": "onramp:cash",
  "asset": "USD",
  "balance": "1000.00",
  "as_of": null,
  "updated_seq": 3
}

balance is normal-side adjusted (a liability holding 100 reads "+100", not -100) and rendered with the asset’s precision. Other responses: 401 · 403 forbidden (book outside the token’s scope) · 404 unknown_account · 429 overloaded (DB pool saturation; carries Retry-After: 1).

GET /v1/books/{book}/accounts/{path}/history?after_seq=&limit=

Paginated postings for one account, seq-ascending. limit defaults to 100 (clamped to 1..1000); after_seq is exclusive — pass the previous page’s next.

Response 200 (Paged<PostingView>):

{
  "items": [
    {
      "seq": 3,
      "tx_id": "0190c8a4-...",
      "account": "onramp:cash",
      "amount": {"minor": 100000, "asset": "USD"},
      "direction": "debit",
      "at": "2026-06-04T12:00:00.123456Z"
    }
  ],
  "next": null
}

next is the cursor for the following page, null when exhausted. One transaction’s postings never split across pages. Other responses: 400 invalid_draft (bad after_seq/limit) · 401 · 403 forbidden (book outside the token’s scope) · 404 unknown_account · 429 overloaded (DB pool saturation; carries Retry-After: 1).

GET /v1/transactions/{tx_id}

Committed transaction by UUID. Response 200 (TransactionView): tx_id, book, seq, at, postings (as above), external_refs, metadata. Other responses: 400 invalid_draft (not a UUID) · 401 · 404 not_found (unknown id, or a transaction whose book is outside the token’s scope — indistinguishable by design, so the endpoint is not an existence oracle for transaction ids) · 429 overloaded (DB pool saturation; carries Retry-After: 1).

GET /v1/books/{book}/trial-balance?as_of=

Per-asset debit/credit sums for a book, optionally point-in-time.

Response 200 (TrialBalance):

{
  "book": "onramp",
  "as_of": null,
  "lines": [
    {"asset": "USD", "debits": 100000, "credits": 100000}
  ]
}

Every line balances when the ledger does. Other responses: 401 · 403 forbidden (book outside the token’s scope) · 429 overloaded (DB pool saturation; carries Retry-After: 1).

GET /v1/books/{book}/events?from= — SSE event stream

Server-Sent Events (text/event-stream). Catch-up from storage, then live tail. ?from= and the Last-Event-ID header both mean last seen seq (header wins); the stream starts at that cursor + 1. Omit both to receive everything from seq 1. (The CLI’s talea tail --from is the inclusive variant: it sends from - 1 as the cursor.)

Each event:

id: 3
data: {"seq":3,"at":"2026-06-04T12:00:00.123456Z","kind":"transaction_posted","payload":{...}}

Resume: reconnect with Last-Event-ID set to the last seq you processed. Delivery is at-least-once — dedupe on seq. A stream error arrives as an SSE error event followed by close; reconnect with your cursor. A malformed ?from= returns a 400 invalid_draft envelope; a book outside the token’s scope returns 403 forbidden before any stream output.

On Postgres, each subscription pins one database connection for its lifetime (LISTEN/NOTIFY) — size TALEA_DB_POOL accordingly. On SQLite, subscriptions only see commits made by the same process.

GET /health

Open route. 200 ok with an X-Talea-Backend: postgres | sqlite | log header identifying the store. Sits inside the admission limits: under saturation it returns 503 like everything else, so wire it to load-balancer readiness (busy), not liveness (dead) — see Run on Postgres.

GET /docs, GET /openapi.json

Swagger UI and the OpenAPI 3 document, generated from the code at compile time. Both open.

GET /metrics (separate listener)

Prometheus metrics, exposed only when TALEA_METRICS_BIND is set — on that bind, not the API port. The metric catalogue lives in the README’s Metrics section.

Errors

Every error — including malformed JSON, bad query parameters, wrong content type, and middleware failures — is a tagged JSON envelope: {"error": "<code>", ...fields}.

error code HTTP Extra fields Meaning
invalid_draft 400 / 415 field, reason Malformed or rejected request
unbalanced 400 asset, debit, credit Per-asset debits ≠ credits
invalid_amount 400 amount Amount ≤ 0 or overflow
asset_mismatch 400 account, account_asset, asset Posting’s asset ≠ account’s asset
unauthorized 401 Missing/invalid bearer token
forbidden 403 book Valid token, but its scope does not cover this book ("*" = the global asset registry)
unknown_asset 404 asset Asset not registered
unknown_account 404 account Account not open
not_found 404 what Transaction id not found
already_exists 409 what Conflicting re-registration
constraint_violation 409 account, min_balance, would_be Commit would breach min_balance
timeout 408 Request exceeded the 30 s server deadline; safe to retry with the same idempotency key
overloaded 429 Per-book write queue full, or DB pool saturation on any route (including reads); carries Retry-After: 1. Migration note: pool exhaustion previously surfaced as 500, so monitoring keyed on 5xx should be updated.
overloaded 503 Admission shed (TALEA_MAX_INFLIGHT exceeded); carries Retry-After: 1 — same envelope, the status code carries the distinction
internal 500 message Server bug — never used for user error

All shedding and timeouts assume clients retry with the same idempotency key — that retry is always safe; overload degrades to “retry later”, never “maybe applied twice”.

Server configuration

Env var Default Effect
TALEA_DB_URL — (required) postgres://... or sqlite://path.db (:memory: is rejected)
TALEA_BIND 127.0.0.1:8080 API listener
TALEA_API_TOKEN unset (open mode) Bearer token for /v1; equivalent to an unnamed all-books rw entry
TALEA_TOKENS_FILE unset TOML file of per-book scoped tokens (see Authentication)
TALEA_DB_POOL 10 DB pool size; each Postgres SSE subscriber pins one connection
TALEA_MAX_INFLIGHT 256 Admission limit; excess sheds 503
TALEA_WRITE_QUEUE_DEPTH 256 Per-book write queue; full → 429
TALEA_WRITE_BATCH_MAX 64 Max drafts per group commit
TALEA_HTTP_BATCH_MAX 500 Maximum drafts accepted by POST /v1/transactions/batch; requests exceeding this cap are rejected with 400 (must be ≥ 1)
TALEA_METRICS_BIND unset Optional Prometheus listener

talead serve loads these from .env in the working directory; real environment variables win. See the README Configuration table for the same list in context.