Skip to content
Ledger by RODMEN/A
Menu

Documentation

Getting started

From nothing to a posted, balanced transaction: asset types, accounts, holds and the retry protocol, every response captured from a running instance.

Last updated 2026-09-05.

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_floorMeaning
omitted0.00 — may not go below zero
"0.00"the same, stated explicitly
"-50.00"a 50.00 overdraft facility
nullunbounded — 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_floor meant unbounded, not zero (issuedb #68). Accounts created then kept that regime. Read them back and PATCH any 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