# Ledger — multi-tenant double-entry financial and inventory ledger Base URL: https://ledger.rodmena.co.uk Machine-readable spec: /openapi.json (OpenAPI 3.1) Health: /healthz (GET and HEAD; round-trips to PostgreSQL before answering) 200 {"status":"ok","auth_client_credential":"ok"|"unknown"} 503 when the database is unreachable, OR when auth has affirmatively refused LEDGER'S OWN credential ("auth_client_credential":"rejected") — in that state every customer key answers 503 AUTHZ_CREDENTIAL_REJECTED, which is a fault here and not in your key. "unknown" (auth unreachable or not asked recently) stays 200 on purpose. ## Endpoints GET /healthz none GET /v1/accounts ledger_reader POST /v1/accounts ledger_admin GET /v1/accounts/{account_id} ledger_reader PATCH /v1/accounts/{account_id} ledger_admin GET /v1/accounts/{account_id}/balance ledger_reader GET /v1/accounts/{account_id}/balance-as-of ledger_reader GET /v1/accounts/{account_id}/entries ledger_reader GET /v1/asset-types ledger_reader POST /v1/asset-types ledger_admin GET /v1/sub-tenants ledger_partner POST /v1/sub-tenants ledger_partner PATCH /v1/sub-tenants/{sub_tenant_id} ledger_partner GET /v1/sub-tenants/{sub_tenant_id}/keys ledger_partner POST /v1/sub-tenants/{sub_tenant_id}/keys ledger_partner DELETE /v1/sub-tenants/{sub_tenant_id}/keys/{key_id} ledger_partner POST /v1/transactions ledger_poster GET /v1/transactions/{transaction_id} ledger_reader POST /v1/transactions/{transaction_id}/commit ledger_poster POST /v1/transactions/{transaction_id}/void ledger_poster ## Authentication Authorization: Bearer rak_... Keys are issued by auth.rodmena.co.uk and minted for you by an operator; there is no self-service yet. A key belongs to exactly ONE tenant and cannot be pointed at another. This service holds no key capable of minting a credential it will accept. Roles: ledger_reader (read) | ledger_poster (read+post) | ledger_admin (+ create asset types and accounts) | ledger_partner (administer YOUR OWN clients; see Sub-tenants). ledger_poster includes read. ledger_partner deliberately carries NO posting right: administering a client and transacting on their behalf are different powers. Expiry and revocation are enforced HERE, not by the authorization service, which does not expire keys. ## The five rules that actually bite 1. AMOUNTS ARE DECIMAL STRINGS, never JSON numbers. A JSON number is a float in most parsers and a float has no place in an amount path. Fields ending `_minor` are integer minor units; the same field without that suffix is a decimal string at the asset's scale. 2. EVERY MUTATING REQUEST NEEDS AN `Idempotency-Key` header, scoped per (tenant, endpoint), remembered 7 days. Same key + same body replays the stored response verbatim with `Idempotent-Replay: true`. Same key + DIFFERENT body is 422 IDEMPOTENCY_KEY_MISMATCH. ON AN AMBIGUOUS FAILURE, RETRY WITH THE SAME KEY. Never mint a fresh one to answer an error — that is the double-spend. Derive the key from your source document (e.g. ":") rather than generating it at send time. 3. SET `balance_floor` EXPLICITLY. "0.00" = may not go negative. "-50.00" = an overdraft facility. null = unbounded, for external/control accounts only. Omitting it now means 0.00; before 2026-08-22 omitting it meant UNBOUNDED, so an account created before then may still be unbounded — read it back and check. THE FLOOR IS CHECKED ONCE, ON THE NET POSITION. Entries are summed per account before the check, so an account that would dip below its floor "in the middle" of a committed transaction and end above it never does — there is no intermediate state. Entry order within a transaction is irrelevant, so a movement with several legs on one account (proceeds in, fee out) belongs in ONE transaction; splitting it to be safe only costs you atomicity. HOLDS DO NOT NET. For `pending: true`, only the contra side is held: incoming pending funds do NOT raise `available` until commit. So the netting above does not apply to a hold — the outflow is held in full against the current balance and the matching inflow contributes nothing. Committed and pending are different rules, not the same rule at different times. Floor enforcement applies to `sync` accounts only. An async account has NO floor enforcement — not a floor of zero. 4. REVERSE, NEVER AMEND. `entries` has no UPDATE and no DELETE — not restricted, absent. Correct a posted transaction with a compensating one. There is no edit path and there will not be one. 5. 503 MEANS UNDETERMINED, NOT DENIED. If authorization cannot be reached you get 503 with Retry-After, never 401/403. Back off and retry; your credential is fine. ## Model asset_type (code, kind, scale 0..12) -> account -> transaction -> entries. kind is one of fiat | credit | inventory. There is no 'crypto' kind: a crypto-asset held as stock is 'inventory'. Anything else is 422 INVALID_ASSET_TYPE. Debits equal credits per asset, both sides present, 2..100 entries — enforced at COMMIT by a deferred database trigger, not by application code. Accounts: `normal_side` debit|credit; `balance_tracking` sync (default, locked, exact) or async (rollup-advanced, EVENTUALLY CONSISTENT, cannot carry a floor). Use sync for anything you enforce a limit on. Holds: post with `pending: true` and an `expires_at` carrying an explicit UTC offset. The amount reduces `available` without moving `posted`. Settle with POST /v1/transactions/{id}/commit, release with /void, or let the sweeper expire it. Balances: `available` = posted less anything held. `version` increments on every change. Route money entering or leaving your system through a `world` account — an async, unbounded external counterparty — so every movement is a balanced transfer and your trial balance is zero by construction. ## Sub-tenants — if you are a platform serving your own clients Put each of YOUR customers in a sub-tenant. They are isolated from each other exactly as two unrelated ledger customers are: a credential scoped to one reaches that one only, and a sibling's identifiers return 404. POST /v1/sub-tenants {"name": "...", "kind": "production"|"sandbox"} GET /v1/sub-tenants PATCH /v1/sub-tenants/{id} {"status": "active"|"suspended", "name": "..."} POST /v1/sub-tenants/{id}/keys {"label": "...", "role": "...", "ttl_days": 36500} GET /v1/sub-tenants/{id}/keys DELETE /v1/sub-tenants/{id}/keys/{key_id} Four things worth knowing before you model against it: * A PARTNER KEY CANNOT READ OR WRITE A CLIENT'S LEDGER DATA, only administer the client. Transacting on a client's behalf needs that client's own credential. This is enforced in the database, not in the handlers. * A sub-tenant cannot itself have sub-tenants. Depth is capped at one, because "which plan pays for this call" and "who may administer this" become recursive questions otherwise, and a recursive answer is one a bug gets wrong silently. * `kind` is IMMUTABLE. A sandbox exists so you can make PERMANENT mistakes somewhere that is not production — the journal is append-only and nothing is ever deleted — so letting a sandbox be reclassified would destroy the only guarantee it offers. * USAGE IS METERED AGAINST YOUR ROOT TENANT. Your plan covers the clients you serve; they do not need plans of their own. * SUSPENSION IS ENFORCED, NOT COSMETIC. PATCH a client to {"status": "suspended"} and every key of theirs is refused with 403 TENANT_SUSPENDED on the very next request; set it back to "active" and they resume immediately. Nothing is deleted — their journal is untouched. If YOUR OWN tenant is suspended, so is every client you serve, since their calls are billed to your plan. ## Dates: the ledger is bitemporal `effective_at` on a transaction is when the movement HAPPENED (set it to the date the bank says). `finalized_at` is when the ledger LEARNED of it. They differ by exactly the amount of history you backfill. GET /v1/accounts/{id}/balance-as-of?at=&basis=effective (default) GET /v1/accounts/{id}/balance-as-of?at=&basis=recorded Use `effective` for "what did I have on the 1st"; `recorded` for audit. URL-ENCODE THE OFFSET OR USE Z. A bare `+` in a query string decodes to a space, so `?at=2026-07-15T00:00:00+00:00` arrives malformed and the 422 quotes a timestamp back at you that looks like your own data is wrong. `%2B` or `Z` both work. The `balance` sign follows the account's `normal_side`, which the response echoes: debit-normal is debits - credits, credit-normal is credits - debits. ## Errors `{"error": {"code": "...", "message": "..."}}`, sometimes with `details`. Branch on `code`, never the message. RETRYABLE: any 503, and any 409 carrying Retry-After (LOCK_TIMEOUT, CONFLICT_RETRY, IDEMPOTENCY_IN_FLIGHT, POSTING_FROZEN, ACCOUNT_NOT_ACTIVE). Retry with the SAME key after the delay. POSTING_FROZEN and ACCOUNT_NOT_ACTIVE are operator-toggled state, not a verdict on your request: the same key completes once the freeze is cleared (#87). FINAL: 4xx without Retry-After. Retrying unchanged will not help. EVERY error, from every layer — this application, the framework's own routing and validation (404 NOT_FOUND, 405 METHOD_NOT_ALLOWED, 422 INVALID_REQUEST), and the edge — uses the SAME envelope, so one reader handles them all. Common codes: UNAUTHENTICATED (401) · CREDENTIAL_EXPIRED (401) · FORBIDDEN (403) · ACCOUNT_NOT_FOUND / TRANSACTION_NOT_FOUND / ASSET_TYPE_NOT_FOUND (404) · INVALID_TRANSACTION_STATE (409) · HOLD_EXPIRED (410) · UNBALANCED_TRANSACTION (422) · INSUFFICIENT_AVAILABLE (422) · INVALID_AMOUNT / INVALID_AMOUNT_SCALE (422) · INVALID_FLOOR (422) · INVALID_EXPIRES_AT (422) · IDEMPOTENCY_KEY_MISMATCH (422) · MISSING_IDEMPOTENCY_KEY (400) · TENANT_SUSPENDED (403) · METHOD_NOT_ALLOWED (405) · QUOTA_EXCEEDED (429) · RATE_LIMITED (429) · AUTHZ_UNAVAILABLE (503) · AUTHZ_CREDENTIAL_REJECTED (503). TENANT_SUSPENDED means the credential is valid and the ACCOUNT is stopped — yours, or the parent of the sub-tenant you are using. It is not a permission problem and retrying will not clear it; whoever administers the tenant must reactivate it. AUTHZ_CREDENTIAL_REJECTED (503) is a fault at OUR end, never yours: the ledger's own credential is being refused by the authorization service, so every customer's key is failing. /healthz reports it as auth_client_credential: "rejected". Retry; do not rotate your key on the strength of it. A cross-tenant identifier returns 404, never 403: the API does not confirm that a record you cannot read exists. ## Not built yet — stated rather than omitted - Quota and metering ARE enforced per tenant (TokenGate). Two limits apply together: a monthly call quota and a burst limiter. Exceeding either returns 429 QUOTA_EXCEEDED with Retry-After — honour the header rather than a fixed sleep. An edge rate limit of 10 req/s per source address (burst 20) sits in front of that; it answers 429 RATE_LIMITED in the SAME JSON envelope as every other error, with Retry-After — nginx refuses the request before this application sees it. A body over 64 KiB is refused there too, as 413 PAYLOAD_TOO_LARGE. it is DDoS shielding, not your allowance. If the metering service is unreachable your request is SERVED, not refused — an outage on our side must not stop you moving money — and the unmetered call is recorded so usage can be reconciled afterwards. - No self-service tenant provisioning or key minting, and no tenant console. A human operator provisions a tenant and mints the first key; a PARTNER can then create and key its own clients through /v1/sub-tenants without any further help. - No listing of transactions. Accounts and asset types DO list; transactions do not — you address one by the id returned when you posted it.