Monitoring wallet balances
Real-time balance updates via webhooks, polling as a fallback, alert thresholds, and topping up without downtime
Prepaid wallets run out. The cost of running out during a campaign is measured in rejected orders, not rupees. Plan monitoring and top-ups so that "wallet empty" is a scheduled event, not a surprise.
Two channels are available for tracking balances:
- Wallet webhooks (
wallet.credited/wallet.debited) — every credit and debit fires a real-time event with the full balance delta. Use this as the primary signal. GET /api/v1/wallets— point-in-time balance read. Use as the periodic reconciliation source and to backstop webhook gaps.
The recommended pattern is webhook-primary, polling-secondary.
The shape of the problem
- Wallets have live balances.
- Every order deducts immediately (even async orders — the wallet is debited synchronously; only fulfilment is async).
- Top-ups are via wire transfer — not instant; 1–2 business days for SWIFT.
- Default order behaviour rejects with
INSUFFICIENT_FUNDSthe moment balance dips below the payable amount.
Implication: the top-up lead time is measured in days. The alerting lead time has to match.
Reading balances
GET /api/v1/wallets returns the current state of every wallet on the account.
curl "$HOST/api/v1/wallets" -H "Authorization: Bearer $TOKEN"Each wallet looks like:
{
"id": 1,
"currency_id": 1,
"amount": 5000.00,
"type": "PREPAID"
}amount is the current available balance. It decreases as orders are placed and increases on credits. Resolve currency_id to an ISO 4217 code via GET /api/v1/currencies — cache that lookup, it changes rarely.
Full spec: Wallets reference.
Real-time monitoring via webhooks
Two wallet events fire every time the balance changes:
| Event | Trigger |
|---|---|
wallet.credited | Funds added — typically an account-manager-initiated top-up after a wire settles |
wallet.debited | Funds deducted — every order debit, payout, or manual adjustment |
Both share the same shape:
{
"id": "evt_wal002",
"type": "wallet.debited",
"created_at": "2026-05-15T14:30:00Z",
"data": {
"wallet_id": 1,
"ledger_id": 790,
"amount": "241.25",
"currency": "USD",
"reference_number": "MY_ORDER_001",
"remarks": "USD Wallet deducted by amount 241.25 for order reference code MY_ORDER_001"
}
}| Field | Notes |
|---|---|
wallet_id | Wallet that was credited / debited |
ledger_id | Internal ledger entry ID — useful for audit trail and dedup beyond X-Event-ID |
amount | Decimal string. Don't parse to a JS float without care — use Number(amount) only if you accept precision risk; prefer a decimal library for ledger code |
currency | ISO 4217 code |
reference_number | Order ref / top-up ref / adjustment ref that triggered this movement |
remarks | Human-readable description |
Hook these into the same webhook handler covered by Handling webhooks in production — verify HMAC, dedup on X-Event-ID, ack fast, process in the worker.
Maintaining a real-time balance cache
The wallet events let an integration keep its own balance cache without polling. Add a debit to a running tally on wallet.debited, add a credit on wallet.credited, and the cache stays in sync.
type WalletEvent struct {
WalletID uint64 `json:"wallet_id"`
Amount string `json:"amount"` // decimal string
Currency string `json:"currency"`
ReferenceNumber string `json:"reference_number"`
}
type BalanceCache struct {
mu sync.Mutex
balances map[uint64]decimal.Decimal // shopspring/decimal recommended
}
func (b *BalanceCache) OnEvent(evType string, data json.RawMessage) error {
var ev WalletEvent
if err := json.Unmarshal(data, &ev); err != nil {
return err
}
delta, err := decimal.NewFromString(ev.Amount)
if err != nil {
return err
}
b.mu.Lock()
defer b.mu.Unlock()
switch evType {
case "wallet.credited":
b.balances[ev.WalletID] = b.balances[ev.WalletID].Add(delta)
case "wallet.debited":
b.balances[ev.WalletID] = b.balances[ev.WalletID].Sub(delta)
}
// Fire alerts based on the new balance
checkThresholds(ev.WalletID, b.balances[ev.WalletID])
return nil
}Reconcile the cache against the source of truth
Webhooks are at-least-once and unordered. A retry can arrive after a newer event. Don't trust the cache as the only ledger — every 1–5 minutes, fetch GET /api/v1/wallets and reset the cache to authoritative values. The webhook stream is for alert latency (sub-second), the periodic poll is for correctness.
Polling — fallback + correctness backstop
Even with webhooks wired, periodic polling stays useful for:
- Correctness reconciliation — the cache might drift after a webhook is missed.
- Cold starts — when the service starts, fetch wallets once to seed the cache.
- Webhook outages — if Octopus Cards's webhook delivery is delayed, polling keeps the alerts honest.
Recommended cadences:
| Scenario | Poll frequency | Alert check cadence |
|---|---|---|
| Steady-state prod, webhooks live | Every 1–5 minutes (reconcile) | On every webhook + every poll |
| Campaign day, webhooks live | Every 30 seconds | On every webhook + every poll |
| Webhooks unavailable / batch-only | Every 30 seconds–5 minutes (primary signal) | On every poll |
Don't poll every second — that burns rate-limit on every wallet without changing the alert latency meaningfully.
Alert thresholds
Three tiers per wallet:
| Level | Threshold | Action |
|---|---|---|
| Warning | 20% of typical daily spend | Slack notification to operations |
| Critical | 10% of typical daily spend | Page operations; start wire transfer |
| Block | 2% of typical daily spend | Disable new orders on the integration side; page on-call |
"Typical daily spend" means the 95th-percentile of rolling 7-day spend on that wallet — not the average. Averages hide spikes.
A minimal poll-based watcher
For services without a webhook endpoint (or as a polling fallback for the webhook setup):
type Watcher struct {
Host string
Token string
Critical float64
Block float64
Alert func(walletID uint64, balance float64, level string)
}
func (w *Watcher) Tick(ctx context.Context) error {
req, _ := http.NewRequestWithContext(ctx, "GET",
w.Host+"/api/v1/wallets", nil)
req.Header.Set("Authorization", "Bearer "+w.Token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
var wallets []wallet
if err := json.NewDecoder(resp.Body).Decode(&wallets); err != nil {
return err
}
for _, wallet := range wallets {
switch {
case wallet.Amount < w.Block:
w.Alert(wallet.ID, wallet.Amount, "BLOCK")
case wallet.Amount < w.Critical:
w.Alert(wallet.ID, wallet.Amount, "CRITICAL")
}
}
return nil
}
func (w *Watcher) Run(ctx context.Context) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = w.Tick(ctx)
}
}
}Wire alert into Slack / PagerDuty / Datadog / whatever the team uses.
Pre-flight: balance check before placing the order
When a wallet is genuinely empty, rather than letting INSUFFICIENT_FUNDS errors pile up in logs, gate the order at the integration's own API layer with a pre-flight balance check.
var ErrWalletEmpty = errors.New("wallet empty; top up before retrying")
func PlaceOrder(host, token string, req orderRequest) (*Order, error) {
// 1. Quote
c, err := voucherCharges(host, token, req.ProductID, voucherChargesReq{
Denomination: req.Denomination,
Quantity: req.Quantity,
WalletID: req.WalletID,
})
if err != nil {
return nil, err
}
// 2. Read balance and gate
wallets, err := listWallets(host, token)
if err != nil {
return nil, err
}
var bal float64
for _, w := range wallets {
if w.ID == *req.WalletID {
bal = w.Amount
break
}
}
if bal < c.TotalPayable {
return nil, ErrWalletEmpty
}
// 3. Safe to place
return placeOrder(host, token, req)
}This catches most insufficient-funds cases before touching the order endpoint and gives a typed error the integration can route differently from generic order failures.
Reading transactions
For spend tracking and reconciliation, list transactions filtered by wallet:
curl "$HOST/api/v1/transactions?wallet_id=1&page=1&limit=100" \
-H "Authorization: Bearer $TOKEN"Transactions include every debit (orders) with order_id + amount, every credit (top-ups, refunds) with source + amount, and the running balance after each entry. Use it to:
- Compute daily burn rate for better threshold setting.
- Reconcile local records against Octopus Cards's ledger.
- Investigate a balance mismatch flagged by the webhook-cache reconciler.
Full spec: Transactions reference.
Top-up workflow
When an alert fires:
- Operations initiates a wire transfer per the instructions from the account manager.
- Octopus Cards credits the wallet when the funds settle (typically 1–2 business days for SWIFT).
wallet.creditedfires the moment the credit is applied.- The new balance appears in
GET /api/v1/walletsimmediately on credit. - Alert auto-clears once balance is above the threshold.
Plan for the 1–2 day float: the "critical" threshold should exceed 2 days of expected spend.
Multi-wallet & cross-currency
If the account holds wallets in multiple currencies, monitor all of them. A low-balance alert on EUR doesn't help if the next batch is priced in GBP.
Two patterns:
- Shadow balancing — keep roughly equal balances across wallets, rebalance with FX if needed.
- Per-currency thresholds — each wallet has its own limits based on its own typical spend.
See Cross-currency orders for how currency flows work inside an order.
Summary
wallet.creditedandwallet.debitedare the real-time channel. Wire them into the same webhook handler used for order events — verify, dedup, ack, process.- Polling (
GET /api/v1/wallets) is the correctness backstop. Reconcile the in-memory balance cache against it every 1–5 minutes. - Three-tier thresholds (warning / critical / block) per wallet.
- Top-ups take 1–2 business days — size the "critical" threshold to cover the gap.
- Pre-flight-check balance before bulk orders to fail fast.
- Use
/transactions?wallet_id=…for reconciliation and burn-rate math.
Handling webhooks in production
End-to-end pattern for a production-ready webhook consumer - verification, idempotency, acking fast - across vouchers, topups, and eSIM
Working with the product catalog
Filtering, denominations, availability, blacklists, and a sensible caching strategy across vouchers, topups, and eSIM