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- You generate addresses using your preferred wallet or custody solution
- Import addresses into StableOps via API or dashboard
- StableOps monitors the blockchain for incoming payments
- You receive webhooks when payments are detected, confirmed, or finalized
- 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 reconciliationThe 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
- Go to Settings → Addresses
- Click Import Addresses
- Select chain and asset
- Choose address mode (Single or Shared)
- Paste addresses (one per line)
- 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 keys2. 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 needed4. 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 chainCompliance 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 distinguish4. 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 webhooksNext Steps
- Payment Orders - Creating payment orders with your addresses
- Confirmations - Understanding payment confirmation stages
- Webhooks - Receiving payment notifications