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, notpayment.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:
acceptedAssetslists every chain/asset pair the customer is allowed to pay from. StableOps allocates available candidate addresses; the returnedpaymentInstructionslet the frontend choose a chain based on the user's wallet.amountis a decimal asset-unit string ("50.00"USDC, not50_000_000).expiresAtis 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.finalizedevents from the API and diff against yourprocessed_eventstable. The diff should always be empty.
How is this guide?
Last updated