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
| Concern | Recommendation |
|---|---|
| Long-running process | For BTC deposits, the monitor may run for 3+ hours. Run in a background worker/queue, not in a request handler. |
| Multiple concurrent deposits | Run one monitor instance per cardId — they're independent. Use a queue with one job per expected deposit. |
| Deposit not arriving | Timeout 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 completing | Timeout at pending stage — may indicate an off-ramp liquidity issue for exotic assets. Raise a support ticket with priority: 2. |
| Suspense account risk | Confirm the card is issuerCardStatus = 1 before starting the monitor. See Funding & Deposits: Suspense accounts. |
