Payment — moving money
Every movement produces balanced double-entry ledger records. There is no endpoint that changes a balance without writing a ledger pair, which is why a balance and its history can never disagree.
Money in — POST /issuances
One wallet on one side, the SYSTEM wallet on the other, so the entry balances.
POST /issuances
Idempotency-Key: <uuid>
{
"type": "NET_EARNINGS",
"walletId": 4021,
"amount": 148.50,
"fundType": "TRANSFERABLE",
"referenceId": "trip-88213",
"description": "Trip payout"
}
type accepts NET_EARNINGS, TIP, BONUS, REIMBURSEMENT and OFFLINE_TOP_UP. Sending a
deduction type here is refused with a 422 naming the endpoint it belongs on.
referenceId is your correlation id — your trip, order or invoice number. You will use it to
reconcile in the next chapter, so set it on everything.
Money out — POST /deductions
The same shape, in the other direction. type accepts CASH_TRANSACTION_DEDUCTION and
CASH_TRANSACTION_REPLACEMENT.
Two rails are deliberately not reachable here: a customer top-up goes through
the payment gateway, and a payout through withdrawal. Asking for
TOP_UP or WITHDRAWAL on either endpoint is a 422 that points you at the right one.
Which types you may post at all is an allowlist configured per tenant, with per-transaction and
daily caps. Posting a type you are not enabled for is 422, and so is breaching a cap. That is
deliberate: it bounds how much e-money you can instruct us to issue.
POST /transactionsThere was, and it is gone. A single write taking a free-text transactionType meant the endpoint
could not tell you which direction the money went until it had parsed the type. The typed pair
above is direction-consistent by construction, and the rails that need their own lifecycle — top-up
and withdrawal — are not reachable through either.
Wallet to wallet — POST /transfers
POST /transfers
Idempotency-Key: <uuid>
{ "fromWalletId": 4021, "toWalletId": 4022, "amount": 50.00 }
Posts two linked transactions sharing one idempotency key. Either both land or neither does.
Undoing — POST /transactions/{id}/reverse
A reversal is a new compensating transaction, never an edit. The original keeps its history and
gains a REVERSED status; the new one carries reversalOf. Ledger entries are append-only —
nothing in this system is ever updated or deleted.
Adjustments
POST /admin/adjustments
Idempotency-Key: <uuid>
{ "walletId": 4021, "amount": 20.00, "direction": "CREDIT", "reason": "Goodwill — ticket 8821" }
reason is mandatory and lands in the audit log.
direction: "DEBIT" is not implementedIt returns 422. An adjustment always credits the wallet in v1.
To take money out, post an ordinary debit transaction with the right type, or reverse the original transaction. Both leave a better audit trail than an adjustment would.
Failures worth handling
| When | Do | |
|---|---|---|
422 balance floor | The debit would take the fund below its floor | Surface "insufficient balance". Whether a wallet may go negative, and how far, is per-tenant configuration |
422 type not enabled | The transaction type is not on your allowlist | Configuration, not code — talk to us |
422 cap exceeded | Per-transaction or daily issuance cap | Retrying will not help today |
422 wallet frozen | The wallet is not ACTIVE | Unfreeze, or fail the operation |
409 | Same key still in flight | Back off, retry with the same key |
What is true afterwards
GET /transactions/{id} shows COMPLETED; the wallet balance has moved by exactly the amount; and
GET /ledger/entries contains a balanced debit/credit pair for it. Assert the ledger, not just the
balance — that is the check that catches a genuine bug.
Next: Ledger.