GuidesWalkthroughs

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_FUNDS the 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:

EventTrigger
wallet.creditedFunds added — typically an account-manager-initiated top-up after a wire settles
wallet.debitedFunds 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"
  }
}
FieldNotes
wallet_idWallet that was credited / debited
ledger_idInternal ledger entry ID — useful for audit trail and dedup beyond X-Event-ID
amountDecimal 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
currencyISO 4217 code
reference_numberOrder ref / top-up ref / adjustment ref that triggered this movement
remarksHuman-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:

ScenarioPoll frequencyAlert check cadence
Steady-state prod, webhooks liveEvery 1–5 minutes (reconcile)On every webhook + every poll
Campaign day, webhooks liveEvery 30 secondsOn every webhook + every poll
Webhooks unavailable / batch-onlyEvery 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:

LevelThresholdAction
Warning20% of typical daily spendSlack notification to operations
Critical10% of typical daily spendPage operations; start wire transfer
Block2% of typical daily spendDisable 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:

  1. Operations initiates a wire transfer per the instructions from the account manager.
  2. Octopus Cards credits the wallet when the funds settle (typically 1–2 business days for SWIFT).
  3. wallet.credited fires the moment the credit is applied.
  4. The new balance appears in GET /api/v1/wallets immediately on credit.
  5. 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.credited and wallet.debited are 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.

On this page