Balances: ledger vs available
Tell the three balances apart, and know which one belongs in your interface.
Before you begin
Nothing to set up — but this page is worth reading before you build a balance display rather than after. Getting it wrong is the single most common source of support tickets on a new card program, and the symptom appears only once real authorizations start flowing.
How it works
Every account carries three figures:
| Balance | Meaning |
|---|---|
ledger_balance | Money that has settled. What the account actually holds |
held_balance | Money reserved by approved authorizations that have not yet cleared |
available_balance | ledger_balance − held_balance. What can still be spent |
The relationship is arithmetic and always holds:
available_balance = ledger_balance − held_balance
The rule
Show
available_balanceto end users. Reconcile againstledger_balance.
An interface built on ledger_balance shows money the cardholder cannot spend,
because it ignores every hold placed earlier that day. The cardholder sees
S$250, gets declined at S$220, and calls support. Nothing is broken — the
display was answering a different question from the one being asked.
Conversely, reconciling against available_balance is also wrong: holds are
provisional and can vanish without any money moving. Reconcile on settled money
only.
A worked example
One account, followed through a complete authorization lifecycle. Amounts are minor units, as the API returns them; the currency is SGD throughout.
Step 1 — Fund the account with S$250.00
POST /v1/accounts/{id}/credit { "amount": { "amount": 25000, "currency": "SGD" } }
| ledger | held | available | |
|---|---|---|---|
| After funding | 25000 | 0 | 25000 |
Nothing surprising yet. This is the state most balance displays are built and tested against, which is why the bug ships.
Step 2 — Cardholder buys coffee, S$42.50
The merchant requests authorization. We approve it and place a hold.
| ledger | held | available | |
|---|---|---|---|
| After authorization | 25000 | 4250 | 20750 |
No money has moved. ledger_balance is untouched. The cardholder can now
spend S$207.50, not S$250.00.
A display reading ledger_balance still says S$250.00 here. That is the bug, and
it is invisible until someone tries to spend the difference.
Step 3 — The merchant clears, at S$46.00
Next day, a tip is added and the merchant submits the transaction for real. The hold is released and an actual debit is posted — for a different amount than was authorized.
| ledger | held | available | |
|---|---|---|---|
| After clearing | 20400 | 0 | 20400 |
25000 − 4600 = 20400. The S$42.50 hold released in full; the S$46.00 charge
posted against the ledger.
This is normal. Restaurants, fuel pumps, and hotels all authorize one amount and clear another. Any reconciliation that assumes authorized equals cleared will break on the first tip.
The whole sequence
| Event | ledger | held | available | Money moved? |
|---|---|---|---|---|
| Opening | 0 | 0 | 0 | — |
| Credit S$250.00 | 25000 | 0 | 25000 | Yes |
| Authorize S$42.50 | 25000 | 4250 | 20750 | No |
| Clear at S$46.00 | 20400 | 0 | 20400 | Yes |
Two edge cases worth knowing
Holds that never clear. An authorization that is neither cleared nor
reversed eventually expires, and the hold is released with no money moving.
Until then those funds are unavailable — which is the usual explanation when a
cardholder insists money is missing after cancelling an online order.
authorization.expired fires when it resolves.
available_balance can go negative. If a transaction clears for more
than it was authorized for, and the difference exceeds what is left, the account
goes overdrawn. Take the example above with only S$44.00 funded:
| Event | ledger | held | available |
|---|---|---|---|
| Credit S$44.00 | 4400 | 0 | 4400 |
| Authorize S$42.50 | 4400 | 4250 | 150 |
| Clear at S$46.00 | −200 | 0 | −200 |
Handle a negative balance in your interface. Do not assume the field is
unsigned, do not clamp it to zero, and do not crash on it. Subsequent
authorizations decline with insufficient_funds until the account is credited.
Verifying
Read the account at each step and assert on all three fields:
curl "$ALETA_BASE_URL/v1/accounts/acct_01HX8ZQ2K4M7P9RSTVWXYZAB" \
-H "Authorization: Bearer $ALETA_ACCESS_TOKEN"
In sandbox you can drive the whole sequence yourself — and the assertions in the middle are the ones that catch real bugs:
POST /v1/simulate/authorization { "card_id": "...", "amount": { "amount": 4250, "currency": "SGD" } }
# assert available dropped by 4250, ledger unchanged
POST /v1/simulate/clearing { "authorization_id": "...", "amount": { "amount": 4600, "currency": "SGD" } }
# assert held is 0, ledger dropped by 4600
An implementation that treats a hold as a debit, or that fails to release a hold on clearing, passes every single-call test and fails this one. See sandbox and test data.
Common errors
| Status | code | What it usually means |
|---|---|---|
404 | account_not_found | Reading a balance with GET /v1/accounts/{account_id}: the account doesn't exist, or belongs to another organization |
402 | insufficient_funds | Checked against available_balance, not ledger_balance. Held funds are not spendable |
422 | currency_mismatch | Comparing or combining money across currencies. Balances are only comparable within one currency |
Related
- Funding and defunding
- Transactions — the authorization lifecycle in full
- Timestamps and money — why amounts are integers