Skip to content

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.

SQLite (default)TigerBeetle
EnableNothing to doSet ZIRZIR_TIGERBEETLE_ADDRESSES
Extra infrastructureNone. Lives in zirzir.dbA TigerBeetle cluster
Good forDevelopment, demos, small merchants and SACCOsProduction volume and replicated durability
FootprintPart 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.

TigerBeetle is a single binary. For a one-replica setup on the same machine:

Terminal window
# 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 it
export ZIRZIR_TIGERBEETLE_ADDRESSES=3000

With 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 --cluster id and set the same value in ZIRZIR_TIGERBEETLE_CLUSTER_ID. Cluster 0 is reserved for testing.
  • Linux 5.6+ only. macOS is fine for development.

See TigerBeetle’s deployment and hardware guides.

Each merchant gets these accounts per currency, kept separately for test and live API keys:

KindMeaningLimit
provider_clearingMoney a provider collected for you and has not paid out yetCannot be credited past its debits
merchant_availableYour balanceCannot go negative
subaccountA split-payment vendor’s balanceCannot go negative
adjustmentsDestination for fees, write-offs and correctionsNone

When a payment reaches completed, zirzir books one atomic batch:

  1. provider_clearing → merchant_available for the full amount.
  2. If the payment has a split: merchant_available → subaccount for 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.

Each account reports balance (posted) and available (posted minus open holds).

Terminal window
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"
}
]
}

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:

Terminal window
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"kind": "adjustments", "currency": "ETB"}' \
http://localhost:8080/api/v1/ledger/accounts

For kind: "subaccount", also pass subaccount_id.

Terminal window
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).

Terminal window
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/transfers

The 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_available or a subaccount: you can only move money you already have. API keys cannot create balance by debiting provider_clearing or adjustments.
  • Sending the same idempotency_key again returns the original transfer with "replayed": true.
  • Insufficient funds returns 409 CONFLICT with exceeds_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.

Terminal window
# Reserve 60.00 ETB for up to 7 days
curl -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 released
curl -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 it
curl -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_minor on 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 returns 409.
  • timeout_secs is optional (at most 90 days). An expired hold is released automatically and its status becomes expired.
  • List holds with GET /api/v1/ledger/holds?status=pending and fetch one with GET /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.

Every completed payment has a posting that books it into the ledger.

Terminal window
# Postings that could not be booked
curl -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/retry

Statuses are pending (queued or retrying), posted and failed. Only failed postings can be retried by hand; pending ones are retried every 30 seconds automatically.

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:

Terminal window
curl -H "X-API-Key: $KEY" "http://localhost:8080/api/v1/ledger/report?from=2026-10-01&to=2026-10-31"
FieldMeaning
payments, collectedPayments settled into the books, and what they brought in
refunds, refundedRefunds completed in the period
provider_feesFees providers kept, booked when their statement is reconciled
vendor_splitsSplit-payment shares paid into vendors’ subaccounts
netCollected less refunds, provider fees and vendor shares (negative when refunds of earlier payments outweigh the period)
paid_outWhat 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.

zirzir ledger works on the books of the project you logged in to with zirzir login:

Terminal window
zirzir ledger accounts # balances
zirzir ledger open adjustments --currency ETB # opens it once, shows it after
zirzir ledger history <account-id> --limit 20 # an account's transfers
zirzir ledger transfer --from <account-id> --to <account-id> \
--amount-minor 1250 --key fix-1 --memo "correction"
zirzir ledger holds --status pending
zirzir ledger capture <hold-id> --amount-minor 2500 # omit the amount to capture it all
zirzir ledger void <hold-id>
zirzir ledger postings --status failed
zirzir 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).

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)

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.