Monitoring STP Deposits

When a digital-asset deposit is in flight, a card's balance moves through two distinct stages: pending (≥10 block confirmations — ledgerBalance updated) and complete (finality reached and off-ramp done — availableBalance updated). This recipe implements a polling monitor that distinguishes both stages, adapts its interval to the asset type, and triggers downstream actions at each transition.


The two-stage deposit lifecycle

stateDiagram-v2
    [*] --> Watching: Deposit address shared with depositor
    Watching --> Pending: ledgerBalance increases\n(≥ 10 confirmations)
    Pending --> Complete: availableBalance increases\n(finality + off-ramp done)
    Watching --> TimedOut: No deposit detected within watch window
    Pending --> TimedOut: Finality/off-ramp not completed within timeout
    Complete --> [*]
    TimedOut --> [*]

Asset-type polling profiles

Different assets have very different confirmation and finality timelines. The monitor adjusts its polling interval accordingly:

const ASSET_PROFILES = {
  // Stablecoins on fast chains (Arbitrum, Base, Polygon, etc.)
  stablecoin_fast: {
    label:               'Stablecoin (fast chain)',
    pendingTimeoutMin:   10,
    completeTimeoutMin:  15,
    pollIntervalMs:      10_000,  // Every 10 s
  },
  // Stablecoins/tokens on Ethereum mainnet
  stablecoin_eth: {
    label:               'Stablecoin (Ethereum)',
    pendingTimeoutMin:   30,
    completeTimeoutMin:  45,
    pollIntervalMs:      30_000,  // Every 30 s
  },
  // BTC — ~10 min per block, 10 confirmations ≈ 100 min
  bitcoin: {
    label:               'Bitcoin',
    pendingTimeoutMin:   120,
    completeTimeoutMin:  180,
    pollIntervalMs:      120_000, // Every 2 min
  },
  // Solana — very fast
  solana: {
    label:               'Solana',
    pendingTimeoutMin:   5,
    completeTimeoutMin:  10,
    pollIntervalMs:      5_000,   // Every 5 s
  },
  // Unknown/exotic — conservative defaults
  default: {
    label:               'Unknown asset',
    pendingTimeoutMin:   180,
    completeTimeoutMin:  360,
    pollIntervalMs:      60_000,  // Every 1 min
  },
};

The deposit monitor

const { api } = require('./client');

async function monitorDeposit(cardId, {
  profile         = 'default',
  expectedAmount  = null,     // optional: minimum amount to confirm
  onPending       = null,     // callback(cardId, ledgerBalance)
  onComplete      = null,     // callback(cardId, availableBalance, ledgerBalance)
  onTimeout       = null,     // callback(cardId, stage)
} = {}) {
  const p = ASSET_PROFILES[profile] ?? ASSET_PROFILES.default;
  console.log(`[${cardId}] Monitoring deposit — profile: ${p.label}`);

  // Snapshot the balance before the deposit to detect the delta
  const initial = await api('GET', `/cards/${cardId}/balance`);
  const baseAvailable = initial.availableBalance ?? 0;
  const baseLedger    = initial.ledgerBalance    ?? 0;

  let stage        = 'watching'; // watching | pending | complete
  let startMs      = Date.now();
  let pendingMs    = null;

  while (true) {
    await sleep(p.pollIntervalMs);

    const { ledgerBalance, availableBalance } = await api('GET', `/cards/${cardId}/balance`);

    // ── Stage: watching → pending ────────────────────────────────────
    if (stage === 'watching') {
      if (ledgerBalance > baseLedger &&
          (!expectedAmount || ledgerBalance - baseLedger >= expectedAmount)) {
        stage     = 'pending';
        pendingMs = Date.now();
        console.log(`[${cardId}] ⏳ PENDING — ledgerBalance: ${ledgerBalance}`);
        await onPending?.(cardId, ledgerBalance);
      } else {
        const elapsedMin = (Date.now() - startMs) / 60_000;
        if (elapsedMin > p.pendingTimeoutMin) {
          console.warn(`[${cardId}] ⚠️  TIMEOUT (watching) — no deposit detected after ${p.pendingTimeoutMin} min`);
          await onTimeout?.(cardId, 'watching');
          return null;
        }
        console.log(`[${cardId}]  · watching... (${Math.round(elapsedMin)}/${p.pendingTimeoutMin} min)`);
      }
    }

    // ── Stage: pending → complete ────────────────────────────────────
    if (stage === 'pending') {
      if (availableBalance > baseAvailable &&
          (!expectedAmount || availableBalance - baseAvailable >= expectedAmount)) {
        stage = 'complete';
        console.log(`[${cardId}] ✅ COMPLETE — availableBalance: ${availableBalance}`);
        await onComplete?.(cardId, availableBalance, ledgerBalance);
        return { ledgerBalance, availableBalance };
      } else {
        const pendingElapsedMin = (Date.now() - pendingMs) / 60_000;
        if (pendingElapsedMin > p.completeTimeoutMin) {
          console.warn(`[${cardId}] ⚠️  TIMEOUT (pending) — off-ramp not complete after ${p.completeTimeoutMin} min`);
          await onTimeout?.(cardId, 'pending');
          return null;
        }
        console.log(`[${cardId}]  · pending, off-ramp in progress... (${Math.round(pendingElapsedMin)}/${p.completeTimeoutMin} min)`);
      }
    }
  }
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

Usage examples

Monitor a stablecoin deposit on Arbitrum

await monitorDeposit(8821, {
  profile:        'stablecoin_fast',
  expectedAmount: 10000,            // $100.00 minimum
  onPending:  (cardId, ledger)    => notifyCardholder(cardId, 'Deposit received — processing'),
  onComplete: (cardId, available) => notifyCardholder(cardId, `$${available/100} ready to spend`),
  onTimeout:  (cardId, stage)     => alertOps(`Deposit timeout on card ${cardId} at stage: ${stage}`),
});

Monitor a Bitcoin deposit

await monitorDeposit(8821, {
  profile: 'bitcoin',
  onPending:  (id, l) => console.log(`BTC deposit pending on card ${id}, ledger: ${l}`),
  onComplete: (id, a) => console.log(`BTC deposit complete on card ${id}, available: ${a}`),
  onTimeout:  (id, s) => raiseSupportTicket(id, `BTC deposit timeout at stage ${s}`),
});

Production considerations

ConcernRecommendation
Long-running processFor BTC deposits, the monitor may run for 3+ hours. Run in a background worker/queue, not in a request handler.
Multiple concurrent depositsRun one monitor instance per cardId — they're independent. Use a queue with one job per expected deposit.
Deposit not arrivingTimeout at watching stage — likely a wrong address, wrong chain, or cardholder hasn't sent yet. Don't assume funds are lost immediately; check with the cardholder first.
Pending but not completingTimeout at pending stage — may indicate an off-ramp liquidity issue for exotic assets. Raise a support ticket with priority: 2.
Suspense account riskConfirm the card is issuerCardStatus = 1 before starting the monitor. See Funding & Deposits: Suspense accounts.

What's next