Skip to content
Ledger by RODMEN/A
Menu

Documentation

API reference

Every endpoint, field and error code of the Ledger API, with the retryable ones marked, plus the machine-readable OpenAPI and llms.txt contracts.

Last updated 2026-09-05.

Base URL https://ledger.rodmena.co.uk. Every response body below was captured from a running instance.

Machine-readable contract

/openapi.json (OpenAPI 3.1) and /llms.txt are served at the base URL.

Conventions

AuthAuthorization: Bearer rak_… on every request except /healthz
Moneydecimal strings at the asset’s scale. _minor fields are integer minor units. Never JSON numbers
IdsUUIDv7 — time-ordered, so they sort chronologically
Mutating requestsrequire Idempotency-Key (1–255 chars), scoped per (tenant, endpoint)
Errors{"error":{"code","message"}}, optionally with details. Branch on code, never the message. Every layer uses this envelope — the application, framework routing/validation errors (404, 405, 422), and edge refusals (429, 413) — so one reader, body.error.code, handles every failure
Cross-tenant ids404, never 403 — the API does not confirm that a record you cannot read exists
PaginationUUIDv7 cursor, limit ≤ 1000
Sizesmetadata ≤ 16 KiB canonical JSON; description ≤ 1024 chars; external_id, name, label, contact_email ≤ 255 chars; JSON nesting ≤ 64 levels; request body ≤ 64 KiB at the edge. Over a cap → 422 naming the field (413 for the body)

Endpoints

GET /healthz

Liveness. Round-trips to PostgreSQL before answering, so a 200 means the database path works. Answers HEAD identically. Never rate limited. No auth.

{"status":"ok","auth_client_credential":"ok"}

503 {"status":"unhealthy","reason":"database unreachable"} when the database is unreachable or unresponsive — it fails fast rather than hanging.

auth_client_credential reports whether ledger’s own credential is still accepted by auth — not yours. It is the difference between “your key is bad” and “ours is”:

ValueMeaningStatus
okauth answered and ledger’s namespace is visible200
rejectedauth answered and ledger’s namespace is not visible — every customer credential is being refused with 503 AUTHZ_CREDENTIAL_REJECTED503
unknownauth was unreachable, unconfigured, or has not been asked recently200

unknown is deliberately not a failure: an outage at auth must not deregister a ledger that is otherwise fine. Only an affirmative refusal degrades the endpoint.

This endpoint never makes a network call of its own — it reports the state the request path last established, so it answers in single-digit milliseconds even while auth is hanging.


POST /v1/asset-typesledger_admin

{"code":"USD","kind":"fiat","scale":2}

201 {"asset_type_id":"01a02aa9-…","code":"USD","kind":"fiat","scale":2}

kind must be one of fiat, credit or inventory — anything else is 422 INVALID_ASSET_TYPE. A commodity or a crypto-asset held as stock is inventory; there is no crypto kind. scale 0–12, immutable once set. code is unique per tenant.


POST /v1/accountsledger_admin

{"asset_type_id":"…","name":"cash","normal_side":"credit",
 "balance_floor":"0.00","balance_tracking":"sync","metadata":{}}

201 {"account_id":"01a02aa9-…","name":"cash","normal_side":"credit"}

FieldNotes
normal_sidedebit or credit. Defines which direction increases the balance
balance_floordecimal string ("0.00"), integer minor units (0), or null for unbounded. Omitted means 0.00; unbounded is opt-in
balance_trackingsync (default, locked + exact) or async (rollup-advanced, eventually consistent, cannot have a floor)

GET /v1/accounts/{id}ledger_reader

The account itself, including the floor it actually carries.

{"account_id":"01a02aa9-…","asset_type_id":"01a02aa9-…","name":"cash",
 "normal_side":"credit","status":"active","balance_tracking":"sync",
 "balance_floor":"0.00","balance_floor_minor":"0","scale":2,
 "created_at":"2026-08-22T18:09:24.931318+00:00"}

balance_floor is null — not "0.00" — for an unbounded account. The two mean opposite things and are never conflated.

When the floor is evaluated. Entries are summed per account before the check, and the floor is then tested once against the resulting net position. An account that would dip below its floor part-way through a committed transaction, and end above it, never does — there is no intermediate state to trip over. Entry order within a transaction is irrelevant, so a movement with several legs on one account (proceeds in, fee out) belongs in a single transaction; splitting it defensively buys nothing and costs you atomicity.

Holds do not net. For a pending transaction (pending: true) only the contra side is held: incoming pending funds do not raise available until commit. The netting above therefore 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.


GET /v1/accounts?limit=&cursor=ledger_reader

{"accounts":[{"account_id":"…",}], "next_cursor":null}

Newest first, keyset-paginated by UUIDv7 like /entries. limit ≤ 1000.


GET /v1/asset-typesledger_reader

{"asset_types":[{"asset_type_id":"…","code":"USD","kind":"fiat","scale":2,
                 "created_at":"…"}]}

Unpaginated: an asset catalogue is bounded by the tenant’s own currency set.


PATCH /v1/accounts/{id}ledger_admin

Updates name, metadata, status, balance_floor only. Nothing else is mutable.

status: activefrozenactive, or → closed. While frozen the account accepts no postings and no hold settlements (commits), including holds placed before the freeze; voids and expiries still release. Closing an account with a non-zero balance is refused with 409 CLOSE_REQUIRES_ZERO_BALANCE. Raising a floor above the current available balance is refused.


POST /v1/transactionsledger_poster

{"description":"opening float",
 "pending":false,
 "expires_at":"2026-12-31T23:59:59Z",
 "entries":[{"account_id":"…","direction":"debit","amount":"250.00"},
            {"account_id":"…","direction":"credit","amount":"250.00"}]}

201

{"state":"committed",
 "accounts":[{"account_id":"…","available_after_minor":"25000","available_after":"250.00"}],
 "created_at":"2026-08-22T18:09:24.931318+00:00",
 "transaction_id":"01a02aa9-c7cc-…"}

2–100 entries. Debits must equal credits per asset type, and both sides must be present — enforced at COMMIT by a deferred database trigger, not by application code. accounts reports available_after for sync-tracked accounts, computed under the same locks that validated the posting.

pending: true creates a hold; expires_at is then required and must carry an explicit UTC offset.


POST /v1/transactions/{id}/commitledger_poster

POST /v1/transactions/{id}/voidledger_poster

Settle or release a pending transaction. Atomic with the hold release. → 200 {"state":"committed","finalized_at":"…","transaction_id":"…"}

A transaction that is already final returns 409 INVALID_TRANSACTION_STATE. One whose hold already expired returns 410 HOLD_EXPIRED. Settling a hold while any of its accounts is frozen is refused with 409 ACCOUNT_NOT_ACTIVE — transient, carries Retry-After; the same key succeeds once the account is active again. Voiding is never blocked: a release always goes through.


GET /v1/accounts/{id}/balanceledger_reader

{"account_id":"…",
 "posted":{"debits":"0.00","credits":"250.00","debits_minor":"0","credits_minor":"25000"},
 "pending":{"debits":"40.00","credits":"0.00","debits_minor":"4000","credits_minor":"0"},
 "available":"210.00","available_minor":"21000",
 "scale":2,"version":2,"as_of":"2026-08-22T18:09:25.056254+00:00"}

available = posted, less anything held. For an async account this is advanced by a background rollup and lags — do not read it as instantaneous truth.


GET /v1/accounts/{id}/balance-as-of?at=ledger_reader

The balance as it stood at a past instant, derived from the journal rather than from the materialised balance row (which holds only the current value).

{"account_id":"…","as_of":"2026-08-01T00:00:00+00:00",
 "posted":{"debits":"0.00","credits":"115.00","debits_minor":"0","credits_minor":"11500"},
 "balance":"115.00","balance_minor":"11500","scale":2,
 "entry_count":2,"balance_tracking":"sync","derived_from":"journal"}

at must carry an explicit UTC offset — a naive timestamp is refused with INVALID_AS_OF, not read as UTC.

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 returns INVALID_AS_OF quoting a timestamp that looks like your data is wrong. Use %2B or Z.

basis selects which clock you are asking about — the ledger is bitemporal:

basisMeansUse for
effective (default)when the movement happened (effective_at)“what did I have on the 1st”
recordedwhen the ledger learned of it (finalized_at)audit, “what did we believe then”

They differ by exactly the amount of history you backfill. Six years of statements imported in an afternoon all carry today’s finalized_at, so basis=recorded reports every historical date as empty — correctly, because nothing was known then.

Set effective_at on POST /v1/transactions to the date the bank says it happened. It also requires an explicit UTC offset, and defaults to the posting time when omitted.

Inclusion is by the transaction’s finalized_at, not the entry’s creation time: an entry exists from the moment its transaction is created but does not count until it commits, so an unsettled hold is excluded and a later-voided one never appears.


GET /v1/accounts/{id}/entries?limit=&cursor=&state=ledger_reader

{"entries":[{"id":"01a02aa9-c843-…","transaction_id":"01a02aa9-c842-…",
             "account_id":"…","direction":"debit",
             "amount":"40.00","amount_minor":"4000","scale":2,
             "line_no":1,"created_at":"2026-08-22T18:09:25.056254+00:00",
             "transaction_state":"committed",
             "finalized_at":"2026-08-22T18:09:25.056254+00:00"}],
 "next_cursor":null}

Newest first. limit ≤ 1000.

Which legs count. The statement serves every journal line for the account — including the legs of holds that are still pending, and of transactions later voided or expired. An entry exists from the moment its transaction is created but counts only once that transaction is committed, so each entry carries transaction_state and the transaction’s finalized_at (null while pending). Pass state=committed to receive only the legs that count; the committed legs sum to the posted counters of GET /balance, and the pending legs to its pending counters. Any other state value is 422 INVALID_STATE. Without state, all legs are served, so an existing cursor stays valid.


GET /v1/transactions/{id}ledger_reader

The transaction with its entries.


Sub-tenants — ledger_partner

If you are a platform serving your own clients, each of them gets a sub-tenant. Sibling sub-tenants are isolated from each other exactly as two unrelated ledger customers are.

MethodPath
POST/v1/sub-tenants{"name","kind":"production"\|"sandbox","contact_email"}
GET/v1/sub-tenants
PATCH/v1/sub-tenants/{id}{"status":"active"\|"suspended","name"}
POST/v1/sub-tenants/{id}/keys{"label","role","ttl_days"} → the secret, once
GET/v1/sub-tenants/{id}/keys
DELETE/v1/sub-tenants/{id}/keys/{key_id}
  • A partner key cannot read or write a client’s ledger data — only administer the client. Transacting on their behalf requires that client’s own credential.
  • A sub-tenant cannot have sub-tenants. Depth is capped at one.
  • kind is immutable. A sandbox is where permanent mistakes are safe; reclassifying one would remove the only guarantee it gives.
  • Usage is metered against the root tenant, so your plan covers your clients.

Error codes

HTTPCodeMeaning
400MISSING_IDEMPOTENCY_KEYmutating request without the header
401UNAUTHENTICATEDmissing, malformed, unknown or revoked credential
401CREDENTIAL_EXPIREDthe key is past its expiry
403FORBIDDENvalid credential, missing permission
404ACCOUNT_NOT_FOUND / TRANSACTION_NOT_FOUND / ASSET_TYPE_NOT_FOUNDnot in your tenant, or does not exist — deliberately indistinguishable
409ACCOUNT_NOT_ACTIVEfrozen or closed account in the posting. Transient — carries Retry-After; retry with the SAME key once the account is active again (a closed account keeps answering 409)
409INVALID_TRANSACTION_STATEcommit/void of an already-final transaction
409DUPLICATE_TRANSACTIONthe durable second dedupe layer caught a repeat
409LOCK_TIMEOUT / CONFLICT_RETRYcontention. Transient — carries Retry-After, retry with the SAME key
409CLOSE_REQUIRES_ZERO_BALANCEclosing a non-empty account
410HOLD_EXPIREDthe hold was swept before you settled it
422UNBALANCED_TRANSACTIONdebits ≠ credits, or a side is missing
422INSUFFICIENT_AVAILABLEthe posting would breach a balance_floor. Includes details naming the account
422INVALID_AMOUNT / INVALID_AMOUNT_SCALEnot a plain decimal string, or too many decimal places for the asset
422INVALID_FLOORbalance_floor is not a decimal string, integer, or null
422INVALID_EXPIRES_ATmissing UTC offset, or in the past
404SUB_TENANT_NOT_FOUNDnot a sub-tenant of yours, or does not exist
404KEY_NOT_FOUNDno such key for that sub-tenant
422INVALID_TENANT / INVALID_KEYmalformed sub-tenant or key request
502REVOCATION_FAILEDthe authorization service refused a revocation; nothing changed
403TENANT_SUSPENDEDthe tenant — or, for a sub-tenant, its parent — is not active. The credential is valid; the account is stopped. Reactivating restores it on the next request
422INVALID_STATEstate on the entries listing is not pending/committed/voided/expired
422INVALID_AS_OFat is not an ISO-8601 instant, carries no UTC offset, or basis is not effective/recorded
422INVALID_EFFECTIVE_ATeffective_at is not an ISO-8601 instant, or carries no UTC offset
422IDEMPOTENCY_KEY_MISMATCHsame key, different body
409IDEMPOTENCY_IN_FLIGHTa concurrent request holds the key. Carries Retry-After
429RATE_LIMITEDedge rate limit (10 req/s, burst 20, per source address). Refused by nginx before the application sees it, in this same JSON envelope. Carries Retry-After — honour it and retry with the same Idempotency-Key. Distinct from QUOTA_EXCEEDED, which is your tenant’s metered allowance
503AUTHZ_UNAVAILABLEthe authorization service could not be reached. Undetermined, not a denial — retry
503STATEMENT_TIMEOUTdatabase statement exceeded its budget
409POSTING_FROZENposting is frozen for this tenant/asset scope while drift is investigated. Transient — carries Retry-After; retry with the SAME key once the operator clears the freeze
409APPEND_ONLY_VIOLATIONsomething attempted to mutate the journal. Should be unreachable through the API
409IMMUTABLE_VIOLATIONan immutable or monotone field was targeted
409TENANT_CONTEXT_REQUIREDinternal: a query reached the database with no tenant context. Fails closed
403TENANT_CONTEXT_MISMATCHinternal: the posting’s tenant disagreed with the session context
403TENANT_NOT_PROVISIONEDthe credential names a tenant this ledger has never provisioned
422ENTRY_COUNT_OUT_OF_RANGEfewer than 2 or more than 100 entries
422REFERENCE_NOT_FOUNDa referenced resource does not exist in this tenant
422CONSTRAINT_VIOLATIONa value violated a database constraint. Backstop for anything boundary validation missed
422INVALID_INPUTa value is malformed or out of domain
422INVALID_ACCOUNT_ID / INVALID_METADATA / INVALID_ACCOUNTmalformed or over-sized field, named in the message
422INVALID_DESCRIPTION / INVALID_EXTERNAL_IDnot a string, or over the size cap
422INVALID_JSON_DEPTHthe body nests deeper than 64 levels (refused before parsing)
413PAYLOAD_TOO_LARGEthe body exceeds 64 KiB at the edge, or 1 MiB at the application. Both answer in this envelope
404NOT_FOUNDa resource referenced by a capability function does not exist, or the path does not exist
405METHOD_NOT_ALLOWEDwrong method for that path. Carries Allow
422INVALID_REQUESTthe request failed validation (a query bound, a malformed path uuid). details carries the per-field diagnostics
500INTERNAL_ERRORunhandled. Never carries internal detail — the traceback is logged, the wire gets a constant

Retryable vs final. Anything 503, and any 409 carrying Retry-After, is transient: retry with the same Idempotency-Key. A 422 is a determination about your request and retrying it unchanged will not help.


What is not here yet

Stated rather than omitted:

  • Quota and metering are enforced per tenant, as a monthly call quota plus a burst limiter. Over either → 429 QUOTA_EXCEEDED with Retry-After. If the metering service is unreachable the request is served rather than refused, and the unmetered call is recorded for reconciliation.
  • No self-service. Tenants and keys are provisioned by an operator (#65/#66).
  • No listing of transactions. Accounts and asset types list; transactions do not — you address them by the id returned when you posted.