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
StableOps does not provide receiving addresses or collect payments on your behalf. Funds go directly to your own wallet address. You import your own addresses:
- 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',
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',
acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
// order1.paymentInstructions[0].address = '0x1234...'
const order2 = await stableops.paymentOrders.create({
merchantOrderId: 'order_2',
amount: '20.00',
acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
// 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',
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
acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
// order1.paymentInstructions[0].address = '0x1234...'
const order2 = await stableops.paymentOrders.create({
merchantOrderId: 'sub_user2_jan',
amount: '10.02', // Different amount
acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
// 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, but exact and auto modes handle it differently:
amount_mode: 'exact'(default): tries single-use addresses before shared ones, each group in import order (createdAt ASC), skips any address that already has the same amount in-flight, and moves to the next address. If no address works, creation fails. Import more addresses or use a different amount.amount_mode: 'auto': allocates in two phases. Phase 1 keeps the base amount untouched and round-robins across addresses (least recently used first) — if the base amount is free on any address, the order gets it with no adjustment. Only when the base amount is in-flight on every address does Phase 2 start micro-adjusting (base + k × 1e-6, up to 1000 steps per address) until a unique amount is found.- 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. If the base amount collides on every address, StableOps nudges it up by the smallest token unit (e.g. 0.000001 USDC) until it is unique among active orders sharing an 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',
acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
// order.amount → '10.00' if the base amount was free on some address,
// otherwise a micro-adjusted value like '10.000001'
// 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,
})
// Import addresses
const result = await stableops.addresses.import({
chain: 'base',
addresses: [
'0x1234567890123456789012345678901234567890',
'0x2345678901234567890123456789012345678901',
'0x3456789012345678901234567890123456789012',
],
mode: 'single', // or 'shared'
})
console.log(`Imported ${result.imported} addresses`)
// Already-imported addresses are skipped silently and don't count toward `imported`Via Dashboard
- Go to Dashboard → Addresses
- Click Import Addresses
- Select chain
- 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',
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 pools = await stableops.addresses.getPools()
console.log('Address pools:')
pools.forEach((pool) => {
console.log(`${pool.chain}:`)
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.`)
}
})Note: Shared addresses always have
status = AVAILABLE, soavailableequalstotalfor shared pools. In-flight allocations are tracked via theAddressAllocationtable and don't affect theallocatedfield.
Low Address Alerts (Single-Use Pools Only)
Note: Low address alerts only apply to pools with exclusively SINGLE addresses. If a chain has at least one SHARED address (shared addresses are reusable and don't need replenishment), the alert is skipped for that chain.
StableOps sends address.pool.low webhooks when available addresses drop below threshold:
{
"type": "address.pool.low",
"data": {
"chain": "base",
"environment": "live",
"available": 2,
"threshold": 3
}
}Pools are tracked per chain (not per chain/asset). The default threshold is 3 and can be overridden with the ADDRESS_POOL_LOW_THRESHOLD environment variable on self-hosted deployments.
Handle low address alerts:
app.post('/webhooks/stableops', async (req, res) => {
const event = req.body
if (event.type === 'address.pool.low') {
const { chain, available } = event.data
// Alert operations team
await sendAlert(`Low on ${chain} addresses: ${available} remaining`)
// Auto-generate more addresses (if using deterministic wallets)
await generateAndImportAddresses(chain, 50)
}
res.sendStatus(200)
})Address Lifecycle
Single-Use Mode (SINGLE):
AVAILABLE → ALLOCATED → AVAILABLE (released after the order reaches a terminal state)Shared Mode (SHARED):
AVAILABLE (always stays AVAILABLE, never transitions to ALLOCATED)SHARED addresses remain AVAILABLE forever — allocations are tracked in the AddressAllocation table, not via address status changes.
States:
- AVAILABLE: Ready to be assigned to a payment order
- ALLOCATED: Currently assigned to an active order (SINGLE addresses only)
- 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',
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',
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',
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',
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',
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',
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 (EVM:
0x+ 40 hex characters; TRON: base58 verbatim) - Normalization: EVM addresses are stored lowercase; no EIP-55 checksum validation is performed
- Duplicates: Importing the same address again is skipped silently
- Chain compatibility: Address must be compatible with chain
Validation Examples
// ✅ Valid EVM address (mixed case accepted, stored lowercase)
'0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'
// ✅ Valid EVM address (lowercase)
'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. Go to Dashboard → Audit Logs to review address import, allocation, and status change history.
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 pools = await stableops.addresses.getPools()
const pool = pools.find((p) => p.chain === 'base')
if (pool && 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).
In exact mode, each amount on a shared address can only belong to one in-flight order at a time. As long as each order has a different amount, they can be distinguished correctly:
// ✅ Good - Different amounts can be distinguished
await stableops.addresses.import({
chain: 'base',
addresses: ['0x1234...'],
mode: 'shared',
})
const order1 = await stableops.paymentOrders.create({
merchantOrderId: `sub_${userId}_jan`,
amount: '10.00',
acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
const order2 = await stableops.paymentOrders.create({
merchantOrderId: `sub_${userId}_feb`,
amount: '15.00',
acceptedAssets: [{ chain: 'base', asset: 'USDC' }],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
// ❌ Bad - Two concurrent orders with the same amount on the same shared address
// cannot be distinguished
// const orderBad = await stableops.paymentOrders.create({
// amount: '10.00', // order1 is still in-flight, this amount is occupied
// ...
// })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,
})
// Import test addresses
await stableops.addresses.import({
chain: 'base-sepolia', // Testnet
addresses: testAddresses,
mode: 'single',
})
// Create test order
const order = await stableops.paymentOrders.create({
merchantOrderId: 'test_order_1',
amount: '0.01',
acceptedAssets: [{ chain: 'base-sepolia', asset: 'USDC' }],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
// Send test payment and verify webhooksNext Steps
- Payment Orders - Creating payment orders with your addresses
- Confirmations - Understanding payment confirmation stages
- Webhooks - Receiving payment notifications
How is this guide?
Last updated