Funding and defunding
Add value to an account, and take it back out.
Before you begin
- An account — see creating accounts.
- An
activecard on the account. Value can only be added or removed while the card isactive; in any other status the call is refused withcard_invalid_state. - An understanding of the three balances. If you have not read balances: ledger vs available, read it first; the difference determines whether a debit succeeds.
These two endpoints move money. A retry without an Idempotency-Key — after a
timeout, a dropped connection, a pod restart mid-request — credits the account
twice.
Generate the key once, before the first attempt, and reuse it for every
retry of that same logical operation. A key derived from your own primary key,
such as topup-8841, survives a process restart in a way a fresh UUID does not.
See idempotency.
The key is forgotten after 24 hours. For the movement itself, also send
client_transaction_id — your own id for this credit or debit, unique per
account, never expiring. A repeat with the same id and amount returns the
account unchanged with Idempotent-Replay: true; a different amount under the
same id is refused. reference is a label, not an identifier: it carries no
uniqueness rule.
How it works
Two operations, mirroring each other:
Effect on ledger_balance | Effect on available_balance | |
|---|---|---|
POST /credit | increases | increases |
POST /debit | decreases | decreases |
Neither touches held_balance — holds belong to authorizations, not to you.
A debit is checked against available_balance, not ledger_balance. Money
reserved by an outstanding authorization cannot be swept out from under it. An
account showing S$250 settled but S$60 held will refuse a S$200 debit with
insufficient_funds, and that is correct: S$60 of that S$250 is already
spoken for.
What this does and does not cover
These endpoints move value on the ledger. They are the record of what a cardholder can spend.
How real money reaches the pooled account backing that ledger — a bank transfer, a payout batch, a collection from your treasury — is arranged per program and is not an API call. Talk to your account contact about the funding rail for your program before you go live; the API side is the easy half.
Steps
1. Credit an account
Call Credit an account for the
account. The request carries the amount, an optional description and reference,
and your own client_transaction_id. Send an Idempotency-Key as well, as
described above.
Amounts are integer minor units in the account's currency. S$250.00 is
25000, and the currency must match the account's.
The response is the account in its new state, so you do not need to read it back.
2. Debit an account
Call Debit an account. It takes the same request, in the opposite direction. Use it to sweep an unspent balance back, correct an over-credit, or empty an account before retiring it.
A debit is checked against available_balance, not ledger_balance — funds held
by pending authorizations are not yours to take back. Too large a debit fails
with insufficient_funds and a 402. The check and the write are one operation,
so an authorization landing between them cannot overdraw the account.
3. Read the funding history
Credits and debits appear as transactions of type adjustment. Call List
transactions filtered by the
account.
Every credit and debit is one entry, and the balance you read back is the sum of
them. A duplicate request carrying the same Idempotency-Key posts once, and so
does one carrying the same client_transaction_id — however long ago the first
was.
Verifying
account.funded fires on credit and account.updated on debit. There is no
low-balance event: poll available_balance from your own scheduler if you
auto-fund, at whatever cadence your float can tolerate.
Read the account back and check that available_balance moved by exactly the
amount you sent. If it moved by a different amount, something else touched the
account concurrently — an authorization clearing, most likely.
Common errors
| Status | code | Fix |
|---|---|---|
402 | insufficient_funds | available_balance is too low. Note this is not 422 |
422 | currency_mismatch | The amount.currency differs from the account's currency. There is no conversion |
422 | account_blocked | Unblock the account first |
400 | invalid_parameter | Usually a float or a major-unit amount. 25000, not 250.00 |
409 | idempotency_key_reuse | Same key, different body. Almost always a retry-logic bug worth fixing rather than working around |
409 | adjustment_already_applied | Same client_transaction_id, different amount or direction. The first movement stands; the message says what it was |
insufficient_funds is never retryableRetrying it will fail identically until the balance changes. Credit the account, then retry — with a new idempotency key, since it is a new operation.