Back to Blog
Engineering
2026-08-1110 min readBy StableOps

Crypto Deposit Monitoring: How Trading Platforms Credit On-Chain Deposits Safely

Learn how crypto deposit monitoring uses unique addresses, finality, idempotent webhooks, and reconciliation to credit customer deposits safely.

Stablecoin payments
Deposit monitoring
Trading infrastructure

Crypto deposit monitoring looks similar to accepting an online payment, but the accounting risk is different. A store releases one product after a fixed invoice is paid. A trading platform accepts a customer-selected network, credits an internal balance, and immediately owes that balance back to the customer. If the same transfer is credited twice, assigned to the wrong user, or reversed after the customer withdraws, the platform loses real funds.

The safe design is to turn every deposit attempt into a short-lived payment order. Persist a local deposit record, create an order with the exact amount and allowed (chain, asset) pairs, allocate a single-use address for each candidate network, and credit the internal ledger only after a verified and deduplicated payment.finalized event. A daily reconciliation job then catches any gap between the webhook stream and the ledger.

This order-based model gives trading platforms, OTC desks, brokerages, gaming balances, and other account-credit products one normalized deposit flow without building a separate scanner for every chain.

A deposit is a liability, not just a checkout payment

The customer experience may be “choose a network, send stablecoins, see a balance,” but the backend has to answer more questions than a checkout page does.

ConcernE-commerce checkoutTrading-platform deposit
Business objectAn invoice or purchaseA request to increase a customer's internal balance
AmountUsually fixed by the sellerChosen by the customer, then fixed exactly for that deposit attempt
NetworkOften narrowed by the merchantCommonly selected from several customer-facing networks
Result of fulfillmentGoods, access, or a subscriptionA liquid platform liability the customer may trade or withdraw
Cost of a false positiveIncorrect fulfillmentDirect financial loss
Safe completion signalVerified, deduplicated payment.finalizedVerified, deduplicated payment.finalized plus an atomic ledger posting

“Variable amount” must not mean “send anything to this address.” The user chooses an amount before the deposit request is created. From that point onward, the chain, asset, receiving address, and smallest-unit amount must match exactly. If the user changes their mind, create a new deposit attempt instead of trying to reinterpret the transfer after it arrives.

Why the obvious monitoring approaches fail

Polling wallet balances cannot identify a transfer

A balance is a snapshot, not an event log. Two users can deposit between polls, treasury automation can sweep funds out, a refund can reduce the balance, and token contracts can emit multiple transfers in one transaction. The balance difference cannot reliably tell you which customer should receive credit.

A home-grown indexer is more than an RPC loop

Reading token-transfer logs is the easy part. A production indexer also needs durable cursors, overlap scans, duplicate suppression, chain-specific address and token normalization, failed-receipt handling, provider failover, confirmation thresholds, and reorganization checks. Every additional chain introduces a different event model and finality mechanism. Even a correct indexer still does not provide the business rule that connects a transfer to one open deposit.

One permanent address per user does not eliminate ambiguity

A permanent user address makes attribution easier, but it creates a long-lived operational identity that must be maintained across chains, custody migrations, compromised addresses, unsupported assets, and years of transaction history. A shared omnibus address is worse: it requires memo support or exact unique amounts and makes late or incorrect transfers harder to assign.

For an intent-based deposit flow, a single-use candidate address is a smaller attribution boundary. It is reserved for one open order and released when that order reaches a terminal state. It is not a permanent customer identifier, so your records must still join by deposit ID, payment-order ID, and transaction reference rather than by address alone.

The order-based crypto deposit monitoring flow

Customer chooses an amount and network


Trading platform persists the deposit intent


StableOps creates the order and allocates candidate addresses


Customer receives the exact amount, chain, asset, and address


Customer sends the stablecoin transfer on-chain


StableOps detects, confirms, and finalizes the transfer


Signed payment.finalized Webhook reaches the trading platform


Platform atomically records the event and ledger credit


Customer balance becomes available

The platform owns the business ledger and the receiving addresses. StableOps allocates imported addresses, monitors supported chains, matches a transfer to the open order, tracks confirmation and finality, and delivers signed events. Funds go directly to addresses whose private keys remain under the platform's control.

1. Persist the deposit intent before creating an order

Create your local record first so every retry has a stable business key:

import { StableOps } from '@stableops/api-sdk'

const client = new StableOps({
  apiKey: process.env.STABLEOPS_API_KEY!,
})

export async function createDeposit(userId: string, amount: string) {
  const deposit = await db.deposits.create({
    data: { userId, requestedAmount: amount, status: 'creating' },
  })

  const order = await client.paymentOrders.create(
    {
      merchantOrderId: deposit.id,
      amount,
      acceptedAssets: [
        { chain: 'base', asset: 'USDC' },
        { chain: 'ethereum', asset: 'USDC' },
        { chain: 'arbitrum', asset: 'USDC' },
        { chain: 'tron', asset: 'USDT' },
      ],
      expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
      metadata: {
        kind: 'deposit',
        user_id: userId,
        deposit_id: deposit.id,
      },
    },
    { idempotencyKey: `deposit:${deposit.id}:create` },
  )

  await db.deposits.update({
    where: { id: deposit.id },
    data: {
      stableopsOrderId: order.id,
      payableAmount: order.amount,
      expiresAt: order.expiresAt,
      status: 'pending',
    },
  })

  return {
    depositId: deposit.id,
    amount: order.amount,
    expiresAt: order.expiresAt,
    paymentInstructions: order.paymentInstructions,
  }
}

merchantOrderId connects the remote order to the local deposit and is unique within the organization and environment. The idempotency key protects request retries; every retry for this deposit must reuse the same key and the same request body.

Each returned paymentInstructions entry supplies one allowed chain, asset, and address. Display the selected entry together with the top-level order.amount and order.expiresAt. Never hard-code the recipient, infer the network from the address, or let the wallet silently edit the amount. StableOps matches the organization, environment, chain, asset, address, and exact smallest-unit amount.

2. Treat early states as progress, not available balance

The normalized lifecycle is the same across supported chains:

created -> detected -> confirmed -> finalized
   |          |            |
expired    reverted     reverted

Use payment.detected to show “deposit received” and payment.confirmed to show “confirming.” Neither state should increase withdrawable or tradable balance. A failed receipt or reorganization can still move a detected or confirmed payment to reverted.

Only payment.finalized reaches the recommended irreversible boundary. StableOps applies chain-specific confirmation and finality rules, so the ledger service does not need an if branch with a hard-coded block count for every network. The stablecoin confirmation guide explains why confirmation depth and finality are separate business decisions.

3. Verify, deduplicate, and post the credit atomically

A successful HTTP delivery does not by itself prove that the customer balance was updated. Verify the signature against the unmodified raw request body, require X-Event-Id, and commit the event inbox record and ledger posting in one database transaction.

type FinalizedDeposit = {
  eventId: string
  paymentOrderId: string
  depositId: string
  userId: string
  amount: string
  asset: string
}

async function creditFinalizedDeposit(input: FinalizedDeposit) {
  await db.$transaction(async (tx) => {
    const accepted = await tx.processedEvents.createMany({
      data: { eventId: input.eventId, eventType: 'payment.finalized' },
      skipDuplicates: true,
    })

    if (accepted.count === 0) return

    const deposit = await tx.deposits.findUniqueOrThrow({
      where: { id: input.depositId },
    })

    if (deposit.stableopsOrderId !== input.paymentOrderId) {
      throw new Error('payment order does not belong to this deposit')
    }

    await tx.ledgerEntries.create({
      data: {
        uniqueKey: `deposit:${input.depositId}`,
        userId: input.userId,
        asset: input.asset,
        amount: input.amount,
        direction: 'credit',
      },
    })

    await tx.deposits.update({
      where: { id: input.depositId },
      data: { status: 'credited', creditedAt: new Date() },
    })
  })
}

The event ID prevents a retried or manually replayed delivery from being processed twice. The independent uniqueKey on the ledger entry protects the financial result even if two different application paths attempt to credit the same deposit. Both constraints belong in the database; an in-memory “already processed” check cannot protect two workers or survive a crash.

In a real handler, resolve the deposit from the saved payment-order ID or verified event fields and compare the user, deposit, amount, and asset with your stored intent before posting. Treat metadata as useful routing context, not as the only financial control.

Expired and late deposits need an exception lane

If no exact transfer is detected before expiresAt, the order moves to expired and its candidate addresses are released. The UI should close that attempt and offer a new deposit request. Do not keep displaying the old address.

A transfer arriving after expiry cannot revive the order. Because an address may already have been reused, operations must investigate the transaction hash, chain, asset, amount, timestamps, and address-allocation history. If the funds are genuine and under your control, record any manual credit or refund as a separate approved ledger action; do not rewrite the expired order as finalized.

The same exception path should cover wrong amounts, wrong assets, and unsupported networks. Underpaid, Overpaid, or Wrong Network provides the operational decision table for those cases.

Reconcile the event stream with the ledger every day

Webhooks are the low-latency path, not the only audit path. A timeout can cause a delivery retry, an endpoint can be unavailable, or your worker can fail after the inbox transaction. Run a daily job that compares:

  1. local deposit intents and credited ledger entries;
  2. StableOps payment orders and their terminal status;
  3. verified payment.finalized inbox events and delivery history;
  4. stored transaction references against the canonical chain when investigating an exception.

Every finalized remote order should have exactly one local deposit, one accepted event, and one ledger credit. Every ledger credit should point back to the local deposit, StableOps order, event ID, asset, and amount. Keep exceptions open until a named operator records the resolution.

This recovery path is especially important for deposits because “the webhook endpoint returned 2xx” is not a ledger assertion. The complete crypto payment reconciliation workflow includes daily checks, month-end controls, delivery replay, and transfer evidence.

Production checklist

  • Require the user to choose the deposit amount before creating the payment order.
  • Persist a local deposit ID and derive both merchantOrderId and the idempotency key from it.
  • Import enough single-use addresses per chain to cover peak open deposits, with a monitored reserve.
  • Render order.amount, order.expiresAt, and the selected chain, asset, and address exactly as returned.
  • Keep trading and withdrawals disabled at detected and confirmed; credit only on verified payment.finalized.
  • Enforce unique constraints on both X-Event-Id and the deposit ledger entry.
  • Close expired attempts, stop showing released addresses, and route late or mismatched transfers to review.
  • Reconcile finalized orders, accepted events, and ledger credits every day.
  • Keep private keys in your wallet or custody system; never put them in StableOps metadata or application logs.

FAQ

Should a trading platform use one deposit address per user or per deposit?

For intent-based deposits, use a single-use address candidate per open payment order. It gives each attempt a clear attribution window without making the address a permanent user identity. If your custody architecture requires permanent user addresses, you still need chain monitoring, finality, duplicate protection, and reconciliation; the address alone does not replace those controls.

When should customer balance become available?

After your backend verifies and deduplicates payment.finalized and atomically writes the ledger entry. payment.detected and payment.confirmed are useful progress states, but making that balance tradable or withdrawable accepts a reorganization risk that is unusually expensive for a trading product.

What happens if a customer deposits after the order expires?

The old order stays expired and the address may have been released. Create a new deposit request for any new payment, then investigate the late transfer separately. If you manually credit or refund it, preserve the original order and add an auditable exception entry rather than changing the payment history.

Build the first deposit path

Start with the trading deposit monitoring guide for an end-to-end implementation, then review Payment Orders for exact matching and address allocation. Test one Sandbox deposit through finalized, replay its webhook, and prove that the customer receives exactly one ledger credit before adding another network.

Related articles

Build AI agent payments with buyer-side policies, budgets, approvals, and customer-controlled signing, plus seller-side payment orders, finality, webhooks, and reconciliation.

Learn crypto payment reconciliation across business orders, payment events, and on-chain transfers with a safe script and daily and monthly checklists.

An underpaid crypto payment, overpayment, wrong-network transfer, or late payment should never silently complete an order. Learn how to detect, resolve, and prevent each mismatch.

There is no single best chain for stablecoin payments. Compare payer-side fees, finality, and wallet distribution, then accept a set of chains and match each transfer to an order.