# Ledger — multi-tenant double-entry financial and inventory ledger Contract build: cfe85ab2b7bea58b86ed929a6d4d2748930263c9 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. ## Decide these five before you design — they are constraints on SHAPE Everything after this block is detail you look up once you know what you are building. These four decide WHAT you can build. Each has already cost an integrator a design or an evening, and each was documented in the right words in the wrong place. 1. A SUB-TENANT CANNOT ITSELF HAVE SUB-TENANTS. Depth is capped at one. If you are multi-tenant yourself and were sketching a tenant-per-customer tree, it does not exist here — you get one level, and you have probably already spent it on your own customers. Detail under "## Sub-tenants". 2. `external_id` IS SET-ONCE. Supply it when you create an account or never: there is no PATCH for it, so accounts created without one cannot be backfilled. Decide your addressing scheme before you create anything. Detail under "## Model". 3. GET /v1/accounts/{account_id}/balance is the CURRENT balance. It is not "as of" anything, and comparing it against an expectation computed for some earlier date is only well-defined while current == as-of. Reconciling history needs /balance-as-of. Detail under "## Dates". 4. A CORRECTED RETRY IS NOT A REPLAY. Same Idempotency-Key with the same body replays; the same key with a CHANGED body is refused 422, permanently. So a key derived as a pure function of (entity, operation) cannot express "the first attempt was wrong, this is the fix" — the failed attempt burned the only key that operation will ever have. Carry an attempt discriminator. Detail in rule 2. 5. TWO READS ARE PAGINATED AND THE REST ARE NOT. GET /v1/accounts and GET /v1/accounts/{account_id}/entries return at most 50 rows by default. Ignore `has_more` and you get a silently INCOMPLETE set — not an error, not a warning, just a short list that looks exactly like a complete one. A trial balance computed over page one balances perfectly and is wrong. Detail under "## Paginated reads". ## Pinning this document Hold a copy, and check it rather than re-reading it hopefully. * THE ENDPOINT TABLE BELOW IS GENERATED at request time from the live OpenAPI document. This file can therefore change because a ROUTE changed, with no edit to the prose and no announcement. A pinned copy is a snapshot of a rendering. * Every response carries `ETag: "sha256:"` over the exact bytes served, and `Last-Modified` naming the build. Send `If-None-Match: ` and an unchanged document answers 304 with no body. * HEAD returns the same validators as GET, so a staleness probe costs one request and no body at all. * THE EDGE COMPRESSES THIS FILE, and marks the validator WEAK when it does — you will see `W/"sha256:"` rather than `"sha256:"`. Same value, same document; the hex is over the UNCOMPRESSED bytes either way. THE FORM FOLLOWS `Accept-Encoding`, NOT THE VERB. Offer gzip and you get the weak form on GET and on HEAD alike; send `Accept-Encoding: identity`, or omit the header, and you get the strong form on both. That matters because a staleness probe written with one client and a fetch written with another routinely disagree about what they offer — so two correct implementations of the same check can compare a weak validator against a strong one and differ for ever on a document that never changed. ALWAYS STRIP A LEADING `W/` BEFORE COMPARING. Both forms are accepted on `If-None-Match` and both return 304. * HASH THE BYTES ON THE WIRE. Shell command substitution strips trailing newlines, so a hash taken of `$(curl ...)` describes a document one byte shorter than the one served — and that mismatch is indistinguishable from a stale pin. ## Endpoints GET /healthz none GET /v1/accounts ledger_reader POST /v1/accounts ledger_admin | ledger_account_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 | ledger_account_admin PATCH /v1/asset-types/{asset_type_id} 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 and amend asset types and accounts) | ledger_partner (administer YOUR OWN clients; see Sub-tenants) | ledger_account_admin (read + CREATE asset types and accounts, and nothing else). ledger_poster includes read. Two roles deliberately carry NO posting right, for the same reason: ledger_partner, because administering a client and transacting on their behalf are different powers; and ledger_account_admin, so a provisioning path that only ever creates accounts need not hold a credential that can move money. ledger_account_admin cannot amend an account either — PATCH stays ledger_admin. 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. BUT A CORRECTED RETRY IS NOT A REPLAY, and these two rules pull opposite ways. If the first request was WRONG and you are fixing it, the fix needs a DIFFERENT key. A key that is a pure function of (entity, operation) — which is what "derive it from your source document" pushes you toward, and which is a sound control against minting a fresh key to escape a failure — cannot express a correction at all: the failed attempt burned the only key that operation will ever have, and every fix is 422 from then on. Carry an attempt discriminator alongside the entity and persist it. ONE EXCEPTION TO "VERBATIM", and it is deliberate: a replayed POST /v1/sub-tenants/{id}/keys does NOT re-serve the secret. The key is shown once, on the original response, and storing it for replay would put a live credential at rest in the idempotency table — which is the guarantee that endpoint prints in its own body. The replay returns the same key_id with a REPLAY warning instead. If you retried and never saw the secret, revoke that key_id and mint a new one under a FRESH Idempotency-Key. 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, status) -> 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. `code`, `kind` and `scale` ARE IMMUTABLE. PATCH /v1/asset-types/{id} refuses all three with 422 IMMUTABLE_FIELD. Outstanding balances are denominated in the asset's minor units, so re-scaling would silently restate every posting ever made against it, and UNIQUE (tenant_id, code) means a rename would free a code that live accounts still mean. GOT ONE WRONG? Mark it superseded — PATCH the asset type with {"status": "superseded"}. New accounts against it are then refused 409 ASSET_TYPE_SUPERSEDED, while every EXISTING account, balance and entry keeps serving unchanged. That is the difference between superseding and deleting, and deleting is not on offer. Create a replacement under a different code. Superseding is a label, not a destruction: PATCH back to "active" if you retired one by mistake. PATCH also takes `description`; nothing else. 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. `external_id` IS YOUR KEY, NOT OURS, AND IT IS SET-ONCE. Optional on create and settable only then — there is no PATCH for it, so an account created without one cannot be backfilled. Unique per tenant, returned on every account read (null when unset). Set it to your own identifier and address accounts by it instead of storing our uuids. A second account with the same external_id is refused 409 DUPLICATE_ACCOUNT — so a double-create is stopped by the database rather than by your code remembering. `name` is a HUMAN LABEL and is NOT unique: never resolve an account by name. TWO SHAPES THAT DIFFER, deliberately flagged because they surprise: POST /v1/accounts returns only {account_id, name, normal_side, external_id}; the GET reads return the full row (asset_type_id, status, balance_floor, balance_floor_minor, scale, balance_tracking, created_at as well). Read the account back if you need the rest. And `balance_floor` accepts a decimal string OR minor-unit integer on POST, but PATCH takes minor-unit integer or null only. ## Paginated reads PAGINATED READS — exactly these, and nothing else: GET /v1/accounts GET /v1/accounts/{account_id}/entries Both return the SAME envelope, and every field is always present: {"accounts": [...], "has_more": true|false, "next_cursor": ""|null} {"entries": [...], "has_more": true|false, "next_cursor": ""|null} `?limit=` defaults to 50 and is capped at 1000. Pass `next_cursor` back as `?cursor=` for the next page. Keyset on UUIDv7, stable descending order, no OFFSET — so a page boundary does not shift under you while rows are being inserted. `has_more` IS TRUE EXACTLY WHEN `next_cursor` IS NON-NULL. They cannot disagree; if you ever see them disagree, raise rather than picking one, because guessing which of two contradictory signals to trust is how a short list becomes a confident wrong total. LOOP UNTIL `has_more` IS FALSE. A caller that reads one page and stops receives a short list indistinguishable from a complete one: no error, no warning, and totals that add up correctly over the wrong set. An integrator hit exactly this — five accounts made it invisible, and it would have started truncating at around a dozen tenants and reported a trial balance BALANCED over an incomplete set. EVERY OTHER LIST READ RETURNS EVERYTHING. GET /v1/asset-types, GET /v1/sub-tenants and GET /v1/sub-tenants/{sub_tenant_id}/keys take no cursor and no limit, because they are bounded by how many you created. The asymmetry is guessable wrong in both directions, which is why it is written down rather than left to be inferred. 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. GET /v1/accounts/{account_id}/balance IS THE CURRENT BALANCE, and this is the trap that costs a reconciliation evening. It is not "as of" anything. Comparing it against an expectation computed for some date holds ONLY while current == as-of: export through day N, compare against an expectation as of day N-1, and one day of postings shows up as a discrepancy that is stable, plausible and entirely fictional. Anything historical wants /balance-as-of with an explicit `at`, and every leg of one comparison wants the SAME basis. 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} role is ledger_reader | ledger_poster | ledger_admin (default ledger_poster). You cannot mint a partner or account-admin key for a client: 422 INVALID_KEY. GET /v1/sub-tenants/{id}/keys DELETE /v1/sub-tenants/{id}/keys/{key_id} Five 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 (including STATEMENT_TIMEOUT), 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) · UNAUTHENTICATED (401) · CREDENTIAL_EXPIRED (401) · FORBIDDEN (403) · TENANT_SUSPENDED (403) · TENANT_CONTEXT_MISMATCH (403) · TENANT_NOT_PROVISIONED (403) · MISSING_IDEMPOTENCY_KEY (400) · INVALID_IDEMPOTENCY_KEY (400) · ACCOUNT_NOT_FOUND / TRANSACTION_NOT_FOUND / ASSET_TYPE_NOT_FOUND / SUB_TENANT_NOT_FOUND / KEY_NOT_FOUND / REFERENCE_NOT_FOUND (404) · METHOD_NOT_ALLOWED (405) · INVALID_TRANSACTION_STATE (409) · IDEMPOTENCY_IN_FLIGHT (409) · TENANT_CONTEXT_REQUIRED (409) · DUPLICATE_ACCOUNT (409) · DUPLICATE_ASSET_TYPE (409) · DUPLICATE_TRANSACTION (409) · ASSET_TYPE_SUPERSEDED (409) · APPEND_ONLY_VIOLATION (409) · IMMUTABLE_VIOLATION (409) · CLOSE_REQUIRES_ZERO_BALANCE (409) · HOLD_EXPIRED (410) · PAYLOAD_TOO_LARGE (413) · UNBALANCED_TRANSACTION (422) · ENTRY_COUNT_OUT_OF_RANGE (422) · INSUFFICIENT_AVAILABLE (422) · INVALID_AMOUNT / INVALID_AMOUNT_SCALE (422) · INVALID_FLOOR (422) · FLOOR_RAISE_REJECTED (422) · INVALID_EXPIRES_AT (422) · IDEMPOTENCY_KEY_MISMATCH (422) · IMMUTABLE_FIELD (422) · INVALID_INPUT (422) · CONSTRAINT_VIOLATION (422) · INVALID_JSON_DEPTH (422) · INVALID_TENANT (422) · QUOTA_EXCEEDED (429) · RATE_LIMITED (429) · INTERNAL_ERROR (500) · AUTHZ_UNAVAILABLE (503) · AUTHZ_CREDENTIAL_REJECTED (503) · STATEMENT_TIMEOUT (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.