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
| Auth | Authorization: Bearer rak_… on every request except /healthz |
| Money | decimal strings at the asset’s scale. _minor fields are integer minor units. Never JSON numbers |
| Ids | UUIDv7 — time-ordered, so they sort chronologically |
| Mutating requests | require 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 ids | 404, never 403 — the API does not confirm that a record you cannot read exists |
| Pagination | UUIDv7 cursor, limit ≤ 1000 |
| Sizes | metadata ≤ 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”:
| Value | Meaning | Status |
|---|---|---|
ok | auth answered and ledger’s namespace is visible | 200 |
rejected | auth answered and ledger’s namespace is not visible — every customer credential is being refused with 503 AUTHZ_CREDENTIAL_REJECTED | 503 |
unknown | auth was unreachable, unconfigured, or has not been asked recently | 200 |
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-types — ledger_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/accounts — ledger_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"}
| Field | Notes |
|---|---|
normal_side | debit or credit. Defines which direction increases the balance |
balance_floor | decimal string ("0.00"), integer minor units (0), or null for unbounded. Omitted means 0.00; unbounded is opt-in |
balance_tracking | sync (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-types — ledger_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: active → frozen → active, 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/transactions — ledger_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}/commit — ledger_poster
POST /v1/transactions/{id}/void — ledger_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}/balance — ledger_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:00arrives malformed and returnsINVALID_AS_OFquoting a timestamp that looks like your data is wrong. Use%2BorZ.
basis selects which clock you are asking about — the ledger is bitemporal:
basis | Means | Use for |
|---|---|---|
effective (default) | when the movement happened (effective_at) | “what did I have on the 1st” |
recorded | when 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.
| Method | Path |
|---|---|
| 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.
kindis 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
| HTTP | Code | Meaning |
|---|---|---|
| 400 | MISSING_IDEMPOTENCY_KEY | mutating request without the header |
| 401 | UNAUTHENTICATED | missing, malformed, unknown or revoked credential |
| 401 | CREDENTIAL_EXPIRED | the key is past its expiry |
| 403 | FORBIDDEN | valid credential, missing permission |
| 404 | ACCOUNT_NOT_FOUND / TRANSACTION_NOT_FOUND / ASSET_TYPE_NOT_FOUND | not in your tenant, or does not exist — deliberately indistinguishable |
| 409 | ACCOUNT_NOT_ACTIVE | frozen 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) |
| 409 | INVALID_TRANSACTION_STATE | commit/void of an already-final transaction |
| 409 | DUPLICATE_TRANSACTION | the durable second dedupe layer caught a repeat |
| 409 | LOCK_TIMEOUT / CONFLICT_RETRY | contention. Transient — carries Retry-After, retry with the SAME key |
| 409 | CLOSE_REQUIRES_ZERO_BALANCE | closing a non-empty account |
| 410 | HOLD_EXPIRED | the hold was swept before you settled it |
| 422 | UNBALANCED_TRANSACTION | debits ≠ credits, or a side is missing |
| 422 | INSUFFICIENT_AVAILABLE | the posting would breach a balance_floor. Includes details naming the account |
| 422 | INVALID_AMOUNT / INVALID_AMOUNT_SCALE | not a plain decimal string, or too many decimal places for the asset |
| 422 | INVALID_FLOOR | balance_floor is not a decimal string, integer, or null |
| 422 | INVALID_EXPIRES_AT | missing UTC offset, or in the past |
| 404 | SUB_TENANT_NOT_FOUND | not a sub-tenant of yours, or does not exist |
| 404 | KEY_NOT_FOUND | no such key for that sub-tenant |
| 422 | INVALID_TENANT / INVALID_KEY | malformed sub-tenant or key request |
| 502 | REVOCATION_FAILED | the authorization service refused a revocation; nothing changed |
| 403 | TENANT_SUSPENDED | the 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 |
| 422 | INVALID_STATE | state on the entries listing is not pending/committed/voided/expired |
| 422 | INVALID_AS_OF | at is not an ISO-8601 instant, carries no UTC offset, or basis is not effective/recorded |
| 422 | INVALID_EFFECTIVE_AT | effective_at is not an ISO-8601 instant, or carries no UTC offset |
| 422 | IDEMPOTENCY_KEY_MISMATCH | same key, different body |
| 409 | IDEMPOTENCY_IN_FLIGHT | a concurrent request holds the key. Carries Retry-After |
| 429 | RATE_LIMITED | edge 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 |
| 503 | AUTHZ_UNAVAILABLE | the authorization service could not be reached. Undetermined, not a denial — retry |
| 503 | STATEMENT_TIMEOUT | database statement exceeded its budget |
| 409 | POSTING_FROZEN | posting 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 |
| 409 | APPEND_ONLY_VIOLATION | something attempted to mutate the journal. Should be unreachable through the API |
| 409 | IMMUTABLE_VIOLATION | an immutable or monotone field was targeted |
| 409 | TENANT_CONTEXT_REQUIRED | internal: a query reached the database with no tenant context. Fails closed |
| 403 | TENANT_CONTEXT_MISMATCH | internal: the posting’s tenant disagreed with the session context |
| 403 | TENANT_NOT_PROVISIONED | the credential names a tenant this ledger has never provisioned |
| 422 | ENTRY_COUNT_OUT_OF_RANGE | fewer than 2 or more than 100 entries |
| 422 | REFERENCE_NOT_FOUND | a referenced resource does not exist in this tenant |
| 422 | CONSTRAINT_VIOLATION | a value violated a database constraint. Backstop for anything boundary validation missed |
| 422 | INVALID_INPUT | a value is malformed or out of domain |
| 422 | INVALID_ACCOUNT_ID / INVALID_METADATA / INVALID_ACCOUNT | malformed or over-sized field, named in the message |
| 422 | INVALID_DESCRIPTION / INVALID_EXTERNAL_ID | not a string, or over the size cap |
| 422 | INVALID_JSON_DEPTH | the body nests deeper than 64 levels (refused before parsing) |
| 413 | PAYLOAD_TOO_LARGE | the body exceeds 64 KiB at the edge, or 1 MiB at the application. Both answer in this envelope |
| 404 | NOT_FOUND | a resource referenced by a capability function does not exist, or the path does not exist |
| 405 | METHOD_NOT_ALLOWED | wrong method for that path. Carries Allow |
| 422 | INVALID_REQUEST | the request failed validation (a query bound, a malformed path uuid). details carries the per-field diagnostics |
| 500 | INTERNAL_ERROR | unhandled. 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_EXCEEDEDwithRetry-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.