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.com - All endpoints under
/v1
1. You need a key
You cannot mint one yourself yet. Ask the ledger operator, who runs:
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, because the authorization service stores only its SHA-256. If you lose it, you get a new key. The old one cannot be recovered.
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, because 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, which is how many decimal places its amounts carry. USD is scale 2, which makes its minor unit a cent.
curl -X POST https://ledger.rodmena.com/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} The scale may be 0–12. Inventory in whole units is scale 0, and 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, which stands for an external counterparty. That is what makes your trial balance zero by construction.
# the external counterparty: unbounded, and not synchronously balance-locked
curl -X POST https://ledger.rodmena.com/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.com/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"} Set balance_floor 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, and 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 means debits must equal credits, per asset, or the write is refused. Between 2 and 100 entries.
curl -X POST https://ledger.rodmena.com/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.com/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, which is the posted balance minus anything held. version increments on every change. You can use it to detect a stale read.
6. Holds, authorised now and 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.com/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.com/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, and the funds return. expires_at must carry an explicit UTC offset. A naive timestamp is refused with INVALID_EXPIRES_AT and never 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, such as a timeout, a dropped connection or 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. Reuse the same key and retry after the delay.
8. Reverse a mistake, never amend it
The ledger.entries table is append-only. There is no UPDATE and no DELETE. Neither operation exists at all.
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