From nothing to a posted, balanced transaction. Every request and response below was captured from a running instance — none of it is illustrative.
- Base URL:
https://ledger.rodmena.co.uk - All endpoints under
/v1
1. You need a key
You cannot mint one yourself yet. Ask the ledger operator; they run:
python -m ledger.admin provision-tenant --name "Your Co" --slug your-co \
--owner-sub <your identity sub> --contact-email ops@your.co
python -m ledger.admin mint-key --tenant <tenant-uuid> --label prod --role ledger_poster The secret is shown once and cannot be re-read — the authorization service stores only its SHA-256. If you lose it, you get a new key, not the old one back.
Every request carries it:
Authorization: Bearer rak_... Roles: ledger_reader (read), ledger_poster (read + post), ledger_admin (read + post +
create asset types and accounts). ledger_poster deliberately includes read — a poster
that cannot read balances is unusable, and posting responses return them.
There is a fourth, off that ladder: ledger_partner administers your own clients as
isolated sub-tenants and carries no posting right at all. If you are integrating on
behalf of other businesses, see Sub-tenants — you will want both a partner
key and a posting key.
2. An asset type
An asset type defines the unit and its scale — how many decimal places its amounts carry. USD is scale 2, so its minor unit is a cent.
curl -X POST https://ledger.rodmena.co.uk/v1/asset-types \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"code":"USD","kind":"fiat","scale":2}' {"asset_type_id":"01a02aa9-8cf8-7e04-96db-da6e7f1221f0","code":"USD","kind":"fiat","scale":2} Scale may be 0–12. Inventory in whole units is scale 0; a token with 8 decimals is scale 8.
3. Accounts
Every movement is a transfer between two accounts you can name. Money entering or
leaving your system comes from a world account — an external counterparty. That is what
makes your trial balance zero by construction rather than by convention.
# the external counterparty: unbounded, and not synchronously balance-locked
curl -X POST https://ledger.rodmena.co.uk/v1/accounts \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"asset_type_id":"<asset>","name":"world","normal_side":"debit",
"balance_tracking":"async","balance_floor":null}'
# a customer wallet that must not go overdrawn
curl -X POST https://ledger.rodmena.co.uk/v1/accounts \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"asset_type_id":"<asset>","name":"cash","normal_side":"credit",
"balance_floor":"0.00"}' {"account_id":"01a02aa9-8d27-7797-acd3-cc605c18bdd7","name":"cash","normal_side":"credit"} balance_floor — set it deliberately
An account’s floor is the line its available balance may not cross. Omitting it means 0.00 (may not go negative), which is the safe default. Unbounded is opt-in and you have
to write it.
balance_floor | Meaning |
|---|---|
| omitted | 0.00 — may not go below zero |
"0.00" | the same, stated explicitly |
"-50.00" | a 50.00 overdraft facility |
null | unbounded — for world / external counterparty accounts only |
Read it back with GET /v1/accounts/{id} to confirm which regime you got.
If your account was created before 2026-08-22, an omitted
balance_floormeant unbounded, not zero (issuedb #68). Accounts created then kept that regime. Read them back andPATCHany that should be floored.
balance_tracking is sync by default: balances update under a row lock as part of the
posting, so a floor is enforced exactly. async opts a hot, unfloored control account out
of that locking; its balance is advanced by a background rollup and is eventually
consistent. A floor cannot be set on an async account. Use sync for anything you
enforce a limit on.
4. Post a transaction
Double entry: debits must equal credits, per asset, or the write is refused. Between 2 and 100 entries.
curl -X POST https://ledger.rodmena.co.uk/v1/transactions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"description":"opening float",
"entries":[{"account_id":"<world>","direction":"debit","amount":"250.00"},
{"account_id":"<cash>","direction":"credit","amount":"250.00"}]}' {"state":"committed",
"accounts":[{"account_id":"01a02aa9-8d27-…","available_after_minor":"25000",
"available_after":"250.00"}],
"created_at":"2026-08-22T18:09:24.931318+00:00",
"transaction_id":"01a02aa9-c7cc-7699-9640-ea4382cd5982"} 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; fields without that suffix are decimal strings at the asset’s scale.
5. Read the balance
curl https://ledger.rodmena.co.uk/v1/accounts/<id>/balance -H "Authorization: Bearer $KEY" {"account_id":"01a02aa9-8d27-…",
"posted":{"debits":"0.00","credits":"250.00","debits_minor":"0","credits_minor":"25000"},
"pending":{"debits":"0.00","credits":"0.00","debits_minor":"0","credits_minor":"0"},
"available":"250.00","available_minor":"25000",
"scale":2,"version":1,"as_of":"2026-08-22T18:09:24.931318+00:00"} available is what you may spend: posted, minus anything held. version increments on
every change, so you can detect a stale read.
6. Holds — authorised now, captured later
For “reserve the funds, settle afterwards”, post with pending: true and an expiry. The
held amount reduces available immediately without moving posted.
curl -X POST https://ledger.rodmena.co.uk/v1/transactions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"pending":true,"expires_at":"2026-12-31T23:59:59Z",
"entries":[{"account_id":"<cash>","direction":"debit","amount":"40.00"},
{"account_id":"<world>","direction":"credit","amount":"40.00"}]}' {"state":"pending","accounts":[{"account_id":"…","available_after":"210.00", …}], …} The balance now shows posted.credits 250.00, pending.debits 40.00, available 210.00.
Then settle it:
curl -X POST https://ledger.rodmena.co.uk/v1/transactions/<id>/commit \
-H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)"
# {"state":"committed","finalized_at":"…","transaction_id":"…"} or release it with /void. A hold left past expires_at is expired automatically by a
background sweeper; the funds return. expires_at must carry an explicit UTC offset —
a naive timestamp is refused with INVALID_EXPIRES_AT rather than silently read as UTC.
7. Idempotency — the part that will bite you
Every mutating request requires an Idempotency-Key header. It is scoped per
(tenant, endpoint) and remembered for 7 days.
A retry with the same key and the same body replays the stored response verbatim and
executes nothing. The reply carries Idempotent-Replay: true:
HTTP/1.1 201 Created
idempotent-replay: true A retry with the same key and a different body is refused:
{"error":{"code":"IDEMPOTENCY_KEY_MISMATCH",
"message":"Idempotency-Key was already used with a different request body"}} On an ambiguous failure — a timeout, a dropped connection, a 5xx — retry with the SAME
key. Never mint a fresh one to answer an error. The intuitive reflex (“that failed, start
clean”) is exactly how you post the same payment twice. A 409 carrying Retry-After is
transient: same key, retry after the delay.
8. Corrections: reverse, never amend
ledger.entries is append-only. There is no UPDATE and no DELETE — not restricted, absent.
A transaction only moves pending → committed | voided | expired.
To correct a posted transaction, post a compensating one. Do not build an “edit transaction” affordance into your UI: once it exists, someone will expect it to work.
Next
- Sub-tenants — give each of your own clients an isolated ledger
- API reference — every endpoint and every error code
- Authentication — how credentials work, and what to do when one leaks
- The product walk-through — the same flow, step by step