Ledger
Every completed payment is booked into a double-entry ledger. Money is stored as integer minor units (ETB 123.45 is 12345), every movement debits one account and credits another, and booked transfers are never edited. Balances are derived from transfers, so they always add up.
Backends
Section titled “Backends”| SQLite (default) | TigerBeetle | |
|---|---|---|
| Enable | Nothing to do | Set ZIRZIR_TIGERBEETLE_ADDRESSES |
| Extra infrastructure | None. Lives in zirzir.db | A TigerBeetle cluster |
| Good for | Development, demos, small merchants and SACCOs | Production volume and replicated durability |
| Footprint | Part of zirzir (tens of MB) | About 1.5 GiB RAM per replica at minimum; 6+ GiB per machine in production |
Both backends behave the same way:
- A batch of transfers is all-or-nothing.
- Replaying an identical batch is a no-op, so retries never double-book.
- Balance limits are enforced by the ledger itself. A merchant’s available balance cannot go negative, and funds reserved by holds count against it.
- A transfer id that failed for a reason that could change later (insufficient funds, a missing account, a missing hold) is spent. Retrying it fails with
id_already_failed; use a new idempotency key. - Holds behave the same: partial captures release the remainder, and expired holds are released automatically.
Every backend runs the same conformance test suite, so these rules are checked rather than promised.
Switching backends starts empty books: history is not copied. Zirzir logs a warning on startup if the backend changed after settlements were booked.
Running TigerBeetle
Section titled “Running TigerBeetle”TigerBeetle is a single binary. For a one-replica setup on the same machine:
# Once: create the data file (preallocates about 1 GiB)tigerbeetle format --cluster=0 --replica=0 --replica-count=1 ./0_0.tigerbeetle
# Run it (use a supervisor such as systemd in production)tigerbeetle start --addresses=3000 ./0_0.tigerbeetle
# Point zirzir at itexport ZIRZIR_TIGERBEETLE_ADDRESSES=3000With Docker Compose, start the bundled service with docker compose --profile tigerbeetle up and set ZIRZIR_TIGERBEETLE_ADDRESSES=tigerbeetle:3000. See server/docker-compose.yml.
For production:
- Run 6 replicas on 6 machines, each with ECC RAM and local NVMe. One replica works, but losing that disk loses the ledger.
- Format with a random
--clusterid and set the same value inZIRZIR_TIGERBEETLE_CLUSTER_ID. Cluster0is reserved for testing. - Linux 5.6+ only. macOS is fine for development.
See TigerBeetle’s deployment and hardware guides.
How payments are booked
Section titled “How payments are booked”Each merchant gets these accounts per currency, kept separately for test and live API keys:
| Kind | Meaning | Limit |
|---|---|---|
provider_clearing | Money a provider collected for you and has not paid out yet | Cannot be credited past its debits |
merchant_available | Your balance | Cannot go negative |
subaccount | A split-payment vendor’s balance | Cannot go negative |
adjustments | Destination for fees, write-offs and corrections | None |
When a payment reaches completed, zirzir books one atomic batch:
provider_clearing→merchant_availablefor the full amount.- If the payment has a split:
merchant_available→subaccountfor the vendor’s share. The share is rounded down to whole minor units, so the merchant keeps any remainder.
Booking happens in the background, so a slow or unreachable ledger never delays the payment or your payment.completed webhook. Postings wait in an outbox (ledger_postings) and are retried every 30 seconds. The same worker also picks up completed payments that have no posting yet, so payments completed before you upgraded are booked automatically. Postings that cannot succeed (for example an unsupported currency) are marked failed and logged for review.
Zero-decimal currencies (UGX, RWF, XOF, XAF) only accept whole amounts when a payment is created, so every payment can be booked.
Supported currencies: ETB, KES, TZS, UGX, RWF, NGN, GHS, ZAR, EGP, XOF, XAF, ZMW, MWK, MZN, CDF, USD, EUR, GBP.
All endpoints use your API key and only see the books for that key’s environment.
List accounts and balances
Section titled “List accounts and balances”Each account reports balance (posted) and available (posted minus open holds).
curl -H "X-API-Key: $KEY" http://localhost:8080/api/v1/ledger/accounts{ "backend": "sqlite", "environment": "live", "accounts": [ { "id": "3f2a…", "kind": "merchant_available", "currency": "ETB", "name": "Available balance (ETB)", "debits_posted": 0, "credits_posted": 29250, "balance_minor": 29250, "balance": "292.50" } ]}Open an account
Section titled “Open an account”Settlement opens the accounts it needs. Open one yourself to get an adjustments account (for fees and write-offs), or to see a zero balance before the first payment:
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"kind": "adjustments", "currency": "ETB"}' \ http://localhost:8080/api/v1/ledger/accountsFor kind: "subaccount", also pass subaccount_id.
Account history
Section titled “Account history”curl -H "X-API-Key: $KEY" "http://localhost:8080/api/v1/ledger/accounts/$ID/transfers?limit=50"Newest first. Each transfer has kind (payment_settlement, split, manual), amount_minor, amount, and reference (the payment id or your idempotency key).
Move money between your accounts
Section titled “Move money between your accounts”curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{ "debit_account_id": "<merchant_available id>", "credit_account_id": "<provider_clearing id>", "amount_minor": 25000, "idempotency_key": "payout-2026-10-13", "memo": "Chapa payout to bank" }' \ http://localhost:8080/api/v1/ledger/transfersThe example books a provider payout: your available balance goes down and the provider no longer owes you that money.
- The debit account must be
merchant_availableor asubaccount: you can only move money you already have. API keys cannot create balance by debitingprovider_clearingoradjustments. - Sending the same
idempotency_keyagain returns the original transfer with"replayed": true. - Insufficient funds returns
409 CONFLICTwithexceeds_credits. - If the ledger is unreachable, the API returns
503 SERVICE_UNAVAILABLE.
Manual transfers are written to the audit log, once per transfer.
A hold reserves money now and settles it later: an order awaiting fulfilment, a payout awaiting approval, a deposit. While a hold is open, its amount is not available, so it cannot be spent twice.
# Reserve 60.00 ETB for up to 7 dayscurl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{ "debit_account_id": "<merchant_available id>", "credit_account_id": "<adjustments id>", "amount_minor": 6000, "idempotency_key": "order-42", "timeout_secs": 604800 }' \ http://localhost:8080/api/v1/ledger/holds
# Capture 25.00 of it; the other 35.00 is releasedcurl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"amount_minor": 2500}' \ http://localhost:8080/api/v1/ledger/holds/$HOLD_ID/capture
# Or release all of itcurl -X POST -H "X-API-Key: $KEY" http://localhost:8080/api/v1/ledger/holds/$HOLD_ID/void- The same debit rule as manual transfers applies: you can only hold money you already have.
- Omit
amount_minoron capture to take the whole hold. A hold is captured or voided at most once; repeating the same capture returns the hold with"replayed": true, and the other action returns409. timeout_secsis optional (at most 90 days). An expired hold is released automatically and its status becomesexpired.- List holds with
GET /api/v1/ledger/holds?status=pendingand fetch one withGET /api/v1/ledger/holds/$HOLD_ID. Statuses:pending,captured,voided,expired.
Account history shows each step with a phase: pending for the hold, post for a capture, void for a release.
Settlement postings
Section titled “Settlement postings”Every completed payment has a posting that books it into the ledger.
# Postings that could not be bookedcurl -H "X-API-Key: $KEY" "http://localhost:8080/api/v1/ledger/postings?status=failed"
# Retry one after fixing the cause (for example an unsupported currency)curl -X POST -H "X-API-Key: $KEY" \ http://localhost:8080/api/v1/ledger/postings/$PAYMENT_ID/retryStatuses are pending (queued or retrying), posted and failed. Only failed postings can be retried by hand; pending ones are retried every 30 seconds automatically.
Period report
Section titled “Period report”What the project collected and kept between two days (UTC, inclusive, at most 366 days), per currency, from the books of the key’s environment:
curl -H "X-API-Key: $KEY" "http://localhost:8080/api/v1/ledger/report?from=2026-10-01&to=2026-10-31"| Field | Meaning |
|---|---|
payments, collected | Payments settled into the books, and what they brought in |
refunds, refunded | Refunds completed in the period |
provider_fees | Fees providers kept, booked when their statement is reconciled |
vendor_splits | Split-payment shares paid into vendors’ subaccounts |
net | Collected less refunds, provider fees and vendor shares (negative when refunds of earlier payments outweigh the period) |
paid_out | What providers paid out to your bank: a movement of cash, not part of net |
Every amount also comes as *_minor. The dashboard’s Accounting page shows the same report, compared with the previous period, with balances by kind of account and CSV exports.
From the CLI
Section titled “From the CLI”zirzir ledger works on the books of the project you logged in to with zirzir login:
zirzir ledger accounts # balanceszirzir ledger open adjustments --currency ETB # opens it once, shows it afterzirzir ledger history <account-id> --limit 20 # an account's transferszirzir ledger transfer --from <account-id> --to <account-id> \ --amount-minor 1250 --key fix-1 --memo "correction"zirzir ledger holds --status pendingzirzir ledger capture <hold-id> --amount-minor 2500 # omit the amount to capture it allzirzir ledger void <hold-id>zirzir ledger postings --status failedzirzir ledger retry <payment-id>Listing commands take --json for scripts. Errors are the server’s own, for example ledger rejected transfer: exceeds_credits (409 Conflict).
From the SDKs
Section titled “From the SDKs”Both SDKs expose the ledger in server mode, with amounts in minor units. Transfers and holds require an idempotency key.
const zirzir = new Zirzir({ baseUrl: 'http://localhost:8080', apiKey: process.env.ZIRZIR_KEY! });
const { accounts } = await zirzir.ledger.accounts();const available = accounts.find((a) => a.kind === 'merchant_available')!;// Opens it the first time, returns it after that.const adjustments = await zirzir.ledger.openAccount({ kind: 'adjustments', currency: 'ETB' });
const history = await zirzir.ledger.history(available.id, { limit: 20 });const { transfer, replayed } = await zirzir.ledger.transfer({ debitAccountId: available.id, creditAccountId: adjustments.id, amountMinor: 1250, idempotencyKey: 'fix-1', memo: 'correction',});
const hold = await zirzir.ledger.createHold({ debitAccountId: available.id, creditAccountId: adjustments.id, amountMinor: 6000, idempotencyKey: 'h-1', timeoutSecs: 600,});await zirzir.ledger.captureHold(hold.id, 2500);
for (const p of await zirzir.ledger.postings({ status: 'failed' })) { await zirzir.ledger.retryPosting(p.transactionId);}zirzir = Zirzir(ServerConfig(base_url="http://localhost:8080", api_key=KEY))
books = await zirzir.ledger.accounts()available = next(a for a in books.accounts if a.kind == "merchant_available")adjustments = await zirzir.ledger.open_account("adjustments", "ETB")
result = await zirzir.ledger.transfer( available.id, adjustments.id, 1250, idempotency_key="fix-1", memo="correction")for p in await zirzir.ledger.postings(status="failed"): await zirzir.ledger.retry_posting(p.transaction_id)In the dashboard
Section titled “In the dashboard”Accounting → Ledger shows every account’s balance and available amount for the current project and mode (test or live). Select an account to see its transfers. Payments whose settlement posting failed are listed above the accounts, each with a Retry button.