Wallets
A wallet is a per-customer balance in a single currency. Wallets fund subscriptions and one-off charges directly, hold reservations against pending captures, carry promotional credit on a separate ledger from cash, and let you issue refunds-as-credit or top-up balances as part of your billing flow. One customer can have multiple wallets - exactly one primary per customer, plus secondaries (typically one extra per currency).
Wallet model
| Field | Notes |
|---|---|
customer_id | Owner. One primary wallet per customer; secondaries are created via POST /v1/wallets for additional currencies |
currency | ISO 4217. The wallet only credits, debits, and funds against this currency |
mode | PREPAID or POSTPAID. Denormalised from the customer - see Mode is per-customer. |
is_primary | True for the one default wallet on the customer; false for additional currencies |
cash_balance | Real-money balance. Drained for invoice payment, refunds, transfers |
promotional_balance | Promotional credit, tracked separately so reporting can distinguish "money in" from "credit given". Subject to promotional_balance_expires_at |
held_balance | Sum of PENDING holds - reserved against the wallet, not available for new spends |
credit_limit, max_balance, max_single_credit, low_balance_threshold | Optional caps - see Limits |
auto_topup_enabled, auto_topup_amount | Auto top-up config - fires when cash_balance falls below low_balance_threshold |
Every monetary field serializes as a Money envelope - { "value": "120.00", "currency": "EUR" }. The wallet's currency field is authoritative; all balance fields must match it.
Mode is per-customer
mode lives on the customer, not the wallet. The wallet object exposes it as a denormalised field for convenience but writing to it is rejected - secondary wallets inherit the customer's mode automatically.
Code
POSTPAID lets cash_balance go negative up to credit_limit; the negative balance is collected on the next invoice cycle. The mode-change endpoint enforces guards against active holds and existing negative cash before relaxing into PREPAID.
Operations
Every credit, debit, adjustment, hold capture, and transfer writes one immutable WalletTransaction row carrying type (CREDIT / DEBIT / ADJUSTMENT), source, the post-op balance_after, and an optional reference / reason.
Credit
Code
Debit (deduct funds)
Code
Hold, capture, release
A hold reserves balance against a future capture without immediately decrementing the spendable balance. Useful for pending operations (in-flight invoices, auctions, escrowed deposits).
The hold create endpoint is on the wallet (singular hold); capture and release operate on the resulting transaction id under /v1/transaction/{id} because holds are modeled as transactions internally.
Code
The response carries the transaction id of the new hold. Use that id when capturing or releasing:
Code
Holds expire automatically when expires_at passes. Active holds for a (subscription_id, metric_key) pair gate fire-and-forget usage events - they must either be captured, released, or referenced by hold_id in the event.
Transfer between wallets
Code
Both wallets must share the same currency. Emits wallet.transferred.
Adjust
Signed correction (positive or negative) for reconciliation against external sources of truth.
Code
Transfer
Code
Cross-currency transfers are supported - the engine resolves an exchange rate, debits the source wallet in source currency, and credits the target wallet in target currency. The response includes converted_amount, exchange_rate_used, and a shared transfer_id linking the debit and credit transactions for audit. Same-currency transfers return both fields as null.
Settings
Code
Sending mode here is explicitly rejected - that change goes through the customer endpoint.
Holds
A hold reserves balance against a future capture without immediately decrementing cash_balance. The held amount accumulates into held_balance so it shows separately from spendable balance. Useful for in-flight invoices, escrow, deposits.
Code
Once placed, the hold can be:
| Action | Endpoint | Effect |
|---|---|---|
| Capture (full or partial) | POST /v1/transaction/{id}/capture (body: optional amount) | Debits cash_balance by the captured amount, sets status: CAPTURED |
| Release | POST /v1/transaction/{id}/release | Releases the hold without debiting, status: VOIDED |
| Expire | automatic at expires_at | Releases the hold, status: EXPIRED, emits wallet.hold_expired |
GET /v1/wallets/{id}/holds lists holds for inspection. Hold statuses: PENDING → terminal CAPTURED / VOIDED / EXPIRED.
Limits and the cascade
The five wallet caps (credit_limit, max_balance, max_single_credit, low_balance_threshold, auto_topup_amount) and auto_topup_enabled resolve through:
Code
Per-customer overrides live on the customer record (default_credit_limit etc.); org-level defaults live in organization_settings. A null after the full cascade means "no cap" for the monetary fields and "disabled" for auto_topup_enabled.
The auto-topup pair has a pairing rule: auto_topup_enabled = true is only effective when an auto_topup_amount is also resolvable. Either alone collapses to disabled - no wallet.auto_topup_triggered events fire for an amount the engine doesn't know.
Auto top-up
When auto_topup_enabled and cash_balance falls below low_balance_threshold, the engine charges the customer's default payment method for auto_topup_amount and credits the wallet on success. Emits wallet.auto_topup_triggered so the trigger reason is auditable.
Transaction history
Code
The active-holds list lives under GET /v1/wallets/{id}/holds.
Wallet vs invoice
Promotional balance
The split into cash_balance + promotional_balance exists because the two have different lifecycle and reporting:
- Cash credit is real money (refund, manual top-up). Doesn't expire.
- Promotional credit is a goodwill or campaign grant. May expire at
promotional_balance_expires_at. Drained first against eligible spends so customers see promo balance disappear before their cash.
A promotion's wallet_credit effect lands here as a PROMOTION-source credit transaction; see Promotions.
Endpoints
All Wallet endpoints - wallet CRUD, credit / debit / adjust / transfer, settings, holds (/wallets/{id}/hold), hold lifecycle (/wallet-holds/{id}/capture, /wallet-holds/{id}/void), transactions, and the customer-scoped GET /v1/customers/{id}/wallet (returns the primary).
Related
- Customers - owns the wallet, the mode, and the per-customer override defaults
- Payments - payment methods that auto top-up draws from
- Credit notes - refund-to-wallet posts here as a
REFUND-source credit - Promotions -
wallet_crediteffects post here asPROMOTION-source credits - Exchange rates - cross-currency transfer FX resolution