StableOps
Concepts

BYO Addresses

Bring Your Own Addresses - import and manage your own blockchain addresses for payment collection.

Bring Your Own Addresses (BYO) allows you to import your own blockchain addresses into StableOps for payment collection. This gives you full control over your funds while leveraging StableOps' payment monitoring, confirmation tracking, and webhook infrastructure.

Overview

Instead of relying on StableOps-generated addresses, you can:

  • Import existing addresses from your wallets or custody solutions
  • Maintain full control of private keys (StableOps never sees them)
  • Use hardware wallets or multi-sig setups for security
  • Integrate with custody providers like Fireblocks, Coinbase Custody, or BitGo
  • Comply with regulations that require self-custody

How BYO Addresses Work

┌─────────────────┐         ┌─────────────┐         ┌─────────────┐
│   Your Wallet   │────────▶│  StableOps  │────────▶│  Your App   │
│  (Private Keys) │         │  (Monitor)  │         │ (Webhooks)  │
└─────────────────┘         └─────────────┘         └─────────────┘
     You control              Monitors only          Receives events
  1. You generate addresses using your preferred wallet or custody solution
  2. Import addresses into StableOps via API or dashboard
  3. StableOps monitors the blockchain for incoming payments
  4. You receive webhooks when payments are detected, confirmed, or finalized
  5. You control funds - withdraw anytime using your private keys

Address Modes

StableOps supports two address allocation modes:

Single-Use Mode (Default)

Each payment order gets a unique address that is used only once.

Characteristics:

  • One address per payment order
  • Address is locked after allocation
  • Released back to pool after completion or expiration
  • Clear payment attribution

Use Cases:

  • E-commerce checkout
  • Invoice payments
  • One-time purchases

Example:

// Import single-use addresses
await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses: [
    '0x1234567890123456789012345678901234567890',
    '0x2345678901234567890123456789012345678901',
    '0x3456789012345678901234567890123456789012',
  ],
  mode: 'SINGLE', // Default
})

// Each order gets a unique address
const order1 = await stableops.paymentOrders.create({
  merchantOrderId: 'order_1',
  amount: '10.00',
  settlementAsset: 'USDC',
  acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
})
// order1.paymentInstructions[0].address = '0x1234...'

const order2 = await stableops.paymentOrders.create({
  merchantOrderId: 'order_2',
  amount: '20.00',
  settlementAsset: 'USDC',
  acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
})
// order2.paymentInstructions[0].address = '0x2345...' (different address)

Shared Mode

Multiple payment orders can share the same address, distinguished by exact amount matching.

Characteristics:

  • One address for multiple orders
  • Requires exact amount match
  • No overpayment or underpayment allowed
  • Address remains available after use

Use Cases:

  • Fixed-price subscriptions
  • API credits (fixed tiers)
  • Recurring payments
  • Scenarios with limited addresses

Example:

// Import shared addresses
await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses: ['0x1234567890123456789012345678901234567890'],
  mode: 'SHARED',
})

// Multiple orders can use the same address
const order1 = await stableops.paymentOrders.create({
  merchantOrderId: 'sub_user1_jan',
  amount: '10.01', // Exact amount
  settlementAsset: 'USDC',
  acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
})
// order1.paymentInstructions[0].address = '0x1234...'

const order2 = await stableops.paymentOrders.create({
  merchantOrderId: 'sub_user2_jan',
  amount: '10.02', // Different amount
  settlementAsset: 'USDC',
  acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
})
// order2.paymentInstructions[0].address = '0x1234...' (same address!)

// Payment of 10.01 USDC matches order1, payment of 10.02 USDC matches order2

⚠️ Shared Mode Limitations:

  • Customer must send exact amount (no rounding)
  • Requires customer education

Same Amount Scenarios:

On a shared address, the same amount can belong to only one in-flight order at a time. StableOps enforces this automatically when allocating:

  • Concurrent same-amount orders: while the first order is still in-flight (created/detected/confirmed), a new order with the same amount is allocated to a different address. If no other address is free, creation fails — import more addresses, use a different amount, or set amount_mode: 'auto'.
  • Reuse after completion: once the first order reaches a terminal state (finalized, reverted, expired, or canceled), its amount is released and can be reused on the same address.

Automatic amount adjustment (amount_mode: 'auto'):

Instead of hand-crafting unique amounts, pass amount_mode: 'auto' when creating the order. StableOps nudges the amount up by the smallest token unit (e.g. 0.000001 USDC) until it is unique among active orders sharing that address — so you submit the same base amount every time and never worry about collisions.

const order = await stableops.paymentOrders.create({
  merchantOrderId: 'sub_user_jan',
  amount: '10.00', // base amount — no need to make it unique yourself
  amountMode: 'auto',
  settlementAsset: 'USDC',
  acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
})

// order.amount          → '10.000001'  the exact amount the customer must pay (already unique)
// order.requestedAmount → '10.00'      your original base amount, for reconciliation

The customer pays the returned amount exactly (6-decimal precision). With the default amount_mode: 'exact', behavior is unchanged and you remain responsible for unique amounts.

Importing Addresses

Via API

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

const stableops = new StableOps({
  apiKey: process.env.STABLEOPS_API_KEY,
  environment: 'production',
})

// Import addresses
const result = await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses: [
    '0x1234567890123456789012345678901234567890',
    '0x2345678901234567890123456789012345678901',
    '0x3456789012345678901234567890123456789012',
  ],
  mode: 'SINGLE', // or 'SHARED'
})

console.log(`Imported ${result.imported} addresses`)
console.log(`Skipped ${result.skipped} duplicates`)

Via Dashboard

  1. Go to SettingsAddresses
  2. Click Import Addresses
  3. Select chain and asset
  4. Choose address mode (Single or Shared)
  5. Paste addresses (one per line)
  6. Click Import

Bulk Import

For large address pools, use bulk import:

// Read addresses from file
const addresses = fs
  .readFileSync('addresses.txt', 'utf-8')
  .split('\n')
  .filter((addr) => addr.trim())

// Import in batches of 100
const BATCH_SIZE = 100
for (let i = 0; i < addresses.length; i += BATCH_SIZE) {
  const batch = addresses.slice(i, i + BATCH_SIZE)

  await stableops.addresses.import({
    chain: 'base',
    asset: 'USDC',
    addresses: batch,
    mode: 'SINGLE',
  })

  console.log(`Imported batch ${i / BATCH_SIZE + 1}`)
}

Address Pool Management

Monitoring Pool Health

StableOps tracks your address pool and sends alerts when running low:

// Get pool status
const overview = await stableops.overview.get()

console.log('Address pools:')
overview.address_pools.forEach((pool) => {
  console.log(`${pool.chain} ${pool.asset}:`)
  console.log(`  Available: ${pool.available}`)
  console.log(`  Allocated: ${pool.allocated}`)
  console.log(`  Total: ${pool.total}`)

  if (pool.available < pool.threshold) {
    console.warn(`⚠️  Low on addresses! Import more soon.`)
  }
})

Low Address Alerts

StableOps sends address.pool.low webhooks when available addresses drop below threshold:

{
  "type": "address.pool.low",
  "data": {
    "chain": "base",
    "asset": "USDC",
    "available": 5,
    "threshold": 10,
    "total": 100
  }
}

Handle low address alerts:

app.post('/webhooks/stableops', async (req, res) => {
  const event = req.body

  if (event.type === 'address.pool.low') {
    const { chain, asset, available } = event.data

    // Alert operations team
    await sendAlert(
      `Low on ${chain} ${asset} addresses: ${available} remaining`,
    )

    // Auto-generate more addresses (if using deterministic wallets)
    await generateAndImportAddresses(chain, asset, 50)
  }

  res.sendStatus(200)
})

Address Lifecycle

AVAILABLE → ALLOCATED → AVAILABLE (released after the order reaches a terminal state)

States:

  • AVAILABLE: Ready to be assigned to a payment order
  • ALLOCATED: Currently assigned to an active order
  • RESERVED: Manually held out of the pool (e.g. earmarked for a hot wallet you don't yet want the platform to allocate)
  • DISABLED: Not allocatable; kept visible for audit purposes only

A SINGLE address returns to AVAILABLE once its order reaches a terminal state (finalized, reverted, expired, or canceled).

Generating Addresses

Hierarchical Deterministic (HD) Wallets

Use HD wallets to generate addresses deterministically:

import { ethers } from 'ethers'

// Generate addresses from mnemonic
const mnemonic = process.env.WALLET_MNEMONIC
const hdNode = ethers.HDNodeWallet.fromPhrase(mnemonic)

const addresses: string[] = []
for (let i = 0; i < 100; i++) {
  const path = `m/44'/60'/0'/0/${i}` // BIP-44 path for Ethereum
  const wallet = hdNode.derivePath(path)
  addresses.push(wallet.address)
}

// Import to StableOps
await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses,
  mode: 'SINGLE',
})

Hardware Wallets

Generate addresses using hardware wallets (Ledger, Trezor):

import TransportNodeHid from '@ledgerhq/hw-transport-node-hid'
import Eth from '@ledgerhq/hw-app-eth'

const transport = await TransportNodeHid.create()
const eth = new Eth(transport)

const addresses: string[] = []
for (let i = 0; i < 100; i++) {
  const path = `44'/60'/0'/0/${i}`
  const { address } = await eth.getAddress(path)
  addresses.push(address)
}

// Import to StableOps
await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses,
  mode: 'SINGLE',
})

Custody Provider Integration

Fireblocks

import { FireblocksSDK } from 'fireblocks-sdk'

const fireblocks = new FireblocksSDK(privateKey, apiKey)

// Generate deposit addresses
const addresses: string[] = []
for (let i = 0; i < 100; i++) {
  const result = await fireblocks.createVaultAccount({
    name: `StableOps-${i}`,
    hiddenOnUI: false,
  })

  const address = await fireblocks.generateNewAddress({
    vaultAccountId: result.id,
    assetId: 'USDC_BASE',
  })

  addresses.push(address.address)
}

// Import to StableOps
await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses,
  mode: 'SINGLE',
})

Coinbase Custody

import { CoinbaseClient } from '@coinbase/coinbase-custody-sdk'

const coinbase = new CoinbaseClient({
  apiKey: process.env.COINBASE_API_KEY,
  apiSecret: process.env.COINBASE_API_SECRET,
})

// Generate addresses
const addresses: string[] = []
for (let i = 0; i < 100; i++) {
  const address = await coinbase.createAddress({
    currency: 'USDC',
    network: 'base',
  })

  addresses.push(address.address)
}

// Import to StableOps
await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses,
  mode: 'SINGLE',
})

Security Best Practices

1. Never Share Private Keys

// ✅ Good - Only import addresses
await stableops.addresses.import({
  addresses: ['0x1234...'],
})

// ❌ Bad - Never send private keys
// StableOps never asks for private keys

2. Use Hardware Wallets for High-Value Addresses

For addresses that will receive large payments:

  • Generate using hardware wallets (Ledger, Trezor)
  • Store private keys offline
  • Use multi-sig for additional security

3. Separate Hot and Cold Addresses

// Hot wallet - for small, frequent payments
await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses: hotWalletAddresses,
  mode: 'SINGLE',
})

// Cold wallet - for large, infrequent payments
// Manually import only when needed

4. Regular Address Rotation

Rotate addresses periodically for privacy:

// Every month, generate new addresses
const rotateAddresses = async () => {
  // Generate new batch
  const newAddresses = await generateAddresses(100)

  // Import to StableOps
  await stableops.addresses.import({
    chain: 'base',
    asset: 'USDC',
    addresses: newAddresses,
    mode: 'SINGLE',
  })

  // Archive old addresses after they're no longer allocated
}

5. Monitor for Unauthorized Withdrawals

Set up alerts for unexpected withdrawals:

// Monitor blockchain for outgoing transactions
const monitorWithdrawals = async (address: string) => {
  const provider = new ethers.JsonRpcProvider(rpcUrl)

  provider.on({ address }, (log) => {
    // Alert if withdrawal not initiated by you
    if (!isAuthorizedWithdrawal(log)) {
      sendSecurityAlert(`Unauthorized withdrawal from ${address}`)
    }
  })
}

Withdrawing Funds

StableOps only monitors addresses - you control withdrawals using your private keys.

Manual Withdrawal

import { ethers } from 'ethers'

const wallet = new ethers.Wallet(privateKey, provider)

// Withdraw USDC
const usdcContract = new ethers.Contract(
  USDC_ADDRESS,
  ['function transfer(address to, uint256 amount) returns (bool)'],
  wallet,
)

const tx = await usdcContract.transfer(
  destinationAddress,
  ethers.parseUnits('100.00', 6), // USDC has 6 decimals
)

await tx.wait()
console.log(`Withdrawn 100 USDC to ${destinationAddress}`)

Automated Sweeping

Automatically sweep funds to a central treasury:

const sweepAddress = async (address: string) => {
  const wallet = new ethers.Wallet(privateKey, provider)
  const usdcContract = new ethers.Contract(USDC_ADDRESS, USDC_ABI, wallet)

  // Get balance
  const balance = await usdcContract.balanceOf(address)

  if (balance > 0) {
    // Transfer to treasury
    const tx = await usdcContract.transfer(TREASURY_ADDRESS, balance)
    await tx.wait()

    console.log(`Swept ${ethers.formatUnits(balance, 6)} USDC from ${address}`)
  }
}

// Sweep all allocated addresses daily
cron.schedule('0 0 * * *', async () => {
  const addresses = await getActiveAddresses()
  for (const address of addresses) {
    await sweepAddress(address)
  }
})

Address Validation

StableOps validates addresses on import:

Validation Rules

  • Format: Must be valid blockchain address format
  • Checksum: EVM addresses must pass checksum validation
  • Duplicates: Cannot import the same address twice
  • Chain compatibility: Address must be compatible with chain

Validation Examples

// ✅ Valid EVM address (checksummed)
'0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'

// ✅ Valid EVM address (lowercase, will be checksummed)
'0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed'

// ✅ Valid TRON address
'TRX9ZfPBvgS8Z8HnFvCfKvFXqvQzMvXkXJ'

// ❌ Invalid - wrong format
'not-an-address'

// ❌ Invalid - wrong chain
// Importing Ethereum address for TRON chain

Compliance and Regulations

Self-Custody Requirements

Some jurisdictions require businesses to maintain custody of customer funds:

  • MiCA (EU): Crypto service providers must hold customer assets
  • NYDFS (New York): BitLicense requires custody arrangements
  • MAS (Singapore): Payment service providers must safeguard funds

BYO Addresses allows you to comply while using StableOps for monitoring.

Audit Trail

StableOps maintains a complete audit trail:

// Query address import history
const auditLogs = await stableops.auditLogs.list({
  resourceType: 'merchant_address',
  action: 'import',
})

auditLogs.items.forEach((log) => {
  console.log(`${log.createdAt}: Imported ${log.metadata.count} addresses`)
  console.log(`  Chain: ${log.metadata.chain}`)
  console.log(`  Asset: ${log.metadata.asset}`)
  console.log(`  Actor: ${log.actor}`)
})

Best Practices

1. Maintain Adequate Pool Size

// Rule of thumb: 2x your daily order volume
const dailyOrders = 100
const recommendedPoolSize = dailyOrders * 2

// Check pool size
const overview = await stableops.overview.get()
const pool = overview.address_pools.find(
  (p) => p.chain === 'base' && p.asset === 'USDC',
)

if (pool.available < recommendedPoolSize) {
  console.warn(
    `Pool too small! Import ${recommendedPoolSize - pool.available} more addresses`,
  )
}

2. Set Up Low Address Alerts

// Configure alert threshold
const ALERT_THRESHOLD = 20 // Alert when < 20 addresses available

app.post('/webhooks/stableops', async (req, res) => {
  const event = req.body

  if (event.type === 'address.pool.low') {
    if (event.data.available < ALERT_THRESHOLD) {
      await sendPagerDutyAlert('Critical: Address pool nearly empty')
    }
  }

  res.sendStatus(200)
})

3. Ensure Unique Amounts in Shared Mode

Tip: Prefer not to hand-pick amounts? Set amount_mode: 'auto' when creating the order and StableOps keeps them unique automatically (see Shared Mode above).

// ✅ Good - Each order has a unique amount
await stableops.addresses.import({
  chain: 'base',
  asset: 'USDC',
  addresses: ['0x1234...'],
  mode: 'SHARED',
})

const order1 = await stableops.paymentOrders.create({
  merchantOrderId: `sub_${userId}_jan`,
  amount: '10.00',
  settlementAsset: 'USDC',
  acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
})

const order2 = await stableops.paymentOrders.create({
  merchantOrderId: `sub_${userId}_feb`,
  amount: '15.00', // Different amount for different month
  settlementAsset: 'USDC',
  acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
})

// ⚠️ Note - When multiple orders have the same amount, system cannot automatically distinguish

4. Document Your Address Generation

// Store address derivation info for recovery
await db.addresses.create({
  address: '0x1234...',
  derivationPath: "m/44'/60'/0'/0/0",
  walletType: 'ledger',
  importedAt: new Date(),
  chain: 'base',
  asset: 'USDC',
})

5. Test Before Production

// Test in sandbox first
const stableops = new StableOps({
  apiKey: process.env.STABLEOPS_SANDBOX_API_KEY,
  environment: 'sandbox',
})

// Import test addresses
await stableops.addresses.import({
  chain: 'base-sepolia', // Testnet
  asset: 'USDC',
  addresses: testAddresses,
  mode: 'SINGLE',
})

// Create test order
const order = await stableops.paymentOrders.create({
  merchantOrderId: 'test_order_1',
  amount: '0.01',
  settlementAsset: 'USDC',
  acceptedAssets: [{ chain: 'base-sepolia', asset: 'USDC' }],
})

// Send test payment and verify webhooks

Next Steps

On this page