StableOps
Guides

Trading deposit monitoring

Monitor multi-chain deposits for a trading product.

This guide walks through the common shape of a trading product: a customer wants to fund their internal balance, can pay from one of several chains, and you need to credit the account the moment the deposit is irreversible.

The same patterns apply to OTC desks, prop firms, gaming top-ups, and any flow where the customer chooses the chain at deposit time and you carry the resulting balance on your side.

What we're building

┌────────────┐  POST /deposits   ┌──────────────┐
│  Customer  │ ─────────────────▶│  Your app    │
└────────────┘                   └──────┬───────┘
                                        │ paymentOrders.create({ metadata: { kind: 'deposit' } })

                                  ┌──────────────┐
                                  │   StableOps  │
                                  └──────┬───────┘
                                         │ payment.finalized

                                  ┌──────────────┐
                                  │  Your ledger │  credit user balance
                                  └──────────────┘

Key decisions for this scenario:

  • Credit on payment.finalized, not payment.confirmed. Trading products have the highest reversal cost: a reorg after you've already let the customer trade means a real loss. Always wait for finality.
  • Tag deposits in metadata. add your own marker (e.g. metadata: { kind: 'deposit' }) so you can group and reconcile deposits in your own analytics.
  • Single-use addresses. Trading deposits are typically variable amount and customer-initiated, so a unique address per deposit avoids the ambiguity of shared-address matching.

1. Create a deposit request

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, 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() + 60 * 60 * 1000).toISOString(),
      metadata: { user_id: userId, deposit_id: deposit.id, kind: 'deposit' },
    },
    { idempotencyKey: `deposit:${deposit.id}:create` },
  )

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

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

A few notes on the request shape:

  • acceptedAssets lists every chain/asset pair the customer is allowed to pay from. StableOps allocates available candidate addresses; the returned paymentInstructions let the frontend choose a chain based on the user's wallet.
  • amount is a decimal asset-unit string ("50.00" USDC, not 50_000_000).
  • expiresAt is optional but recommended. Trading deposits that linger forever clutter the dashboard; a one-hour TTL is a reasonable default.
  • The idempotency key is derived from your persisted deposit id. Every retry for the same deposit must reuse it; generating a fresh key creates a new order instead of replaying the first response.

2. Show the deposit instructions

Each paymentInstructions entry is a complete candidate the customer can send to:

const instruction = order.paymentInstructions[0]
// {
//   chain: 'base',
//   asset: 'USDC',
//   address: '0xabc...123',
// }

For TRON you'll get a base58 T… address; for EVM chains you'll get a lowercased 0x… address. Match those forms verbatim in your UI. The chain scanner stores them the same way and any normalization you do has to use normalizeContractAddress from @stableops/shared.

Render a QR code for address plus a copy button for the amount. Most wallets fill in the amount field for you if you encode it into the QR payload.

3. Handle the webhook

Subscribe to at least payment.finalized. If you want a "Payment received, confirming…" UI state, also subscribe to payment.detected and payment.confirmed.

import { SIGNATURE_HEADER, verifySignature } from '@stableops/api-sdk/webhooks'

export async function POST(req: Request) {
  const rawBody = await req.text()
  const result = verifySignature({
    secrets: [process.env.STABLEOPS_WEBHOOK_SECRET!],
    header: req.headers.get(SIGNATURE_HEADER) ?? undefined,
    rawBody,
  })
  if (!result.ok) return new Response('invalid', { status: 400 })

  const event = JSON.parse(rawBody) as { type: string; data: any }
  const eventId = req.headers.get('x-event-id')!

  if (event.type === 'payment.finalized') {
    await creditUserBalance({
      eventId,
      userId: event.data.metadata.user_id,
      depositId: event.data.metadata.deposit_id,
      stableopsOrderId: event.data.payment_order_id,
      amount: event.data.amount,
      asset: event.data.settlement_asset,
    })
  }

  return new Response('ok')
}

Note that event.data.settlement_asset is only populated for payment.detected, payment.confirmed, payment.finalized, and payment.reverted events. It is null on payment_order.created. The asset is not known until a transaction is detected on-chain.

creditUserBalance must be idempotent on eventId. Webhook deliveries can repeat after network errors or manual replays.

4. Idempotent credit on your ledger

The simplest pattern is a processed_events table with a unique constraint on the event id:

CREATE TABLE processed_events (
  event_id TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
async function creditUserBalance(input: {
  eventId: string
  userId: string
  depositId: string
  stableopsOrderId: string
  amount: string
  asset: string
}) {
  await db.$transaction(async (tx) => {
    try {
      await tx.processedEvents.create({ data: { eventId: input.eventId } })
    } catch (err) {
      if (isUniqueViolation(err)) return // already credited
      throw err
    }

    await tx.balances.update({
      where: { userId_asset: { userId: input.userId, asset: input.asset } },
      data: { available: { increment: input.amount } },
    })

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

The unique insert + transaction is the entire idempotency guarantee. If the event has been processed before, the insert fails and the balance update never runs.

5. Handle payment.reverted

Reverts after finalized are rare but possible if the receipt itself fails or if a deep reorg invalidates the stored block hash. Subscribe to payment.reverted and roll back the credit:

if (event.type === 'payment.reverted') {
  await tx.balances.update({
    where: {
      userId_asset: {
        userId: event.data.metadata.user_id,
        asset: event.data.settlement_asset,
      },
    },
    data: { available: { decrement: event.data.amount } },
  })
  await tx.deposits.update({
    where: { id: event.data.metadata.deposit_id },
    data: { status: 'reverted' },
  })
}

If the customer has already withdrawn the credited balance you'll need your own dispute flow. StableOps cannot recover funds you've already let out of your system. This is the main argument for crediting on finalized and not on confirmed.

Operational checklist

  • Finality, not confirmation. Make sure your fulfillment code keys on payment.finalized.
  • Per-chain confirmation thresholds. Don't hard-code "12 confirmations". StableOps already encodes the right threshold per chain. Trust the state transitions.
  • Monitor payment.expired. Treat expired deposits as canceled in your UI and ask the user to start a new deposit.
  • Address pool depth. For single-use mode, keep enough addresses imported per chain to cover peak concurrent deposits. See BYO Addresses.
  • Reconciliation. Once a day, list payment.finalized events from the API and diff against your processed_events table. The diff should always be empty.

How is this guide?

Last updated

On this page