Sending Payments
Programmatically send ADA from your backend with proper security, batching, and confirmation tracking
In this tutorial, you'll build a production-grade payment sending system that handles outbound payments efficiently and securely from your backend.
What You'll Build
A payment sender that:
- Queues and validates payment requests
- Batches multiple payments into single transactions
- Manages hot wallet UTxOs efficiently
- Tracks transaction confirmations
- Handles failures with proper retry logic
Use Cases
Marketplace Payouts
Pay sellers when items sell or at scheduled intervals
Refund Processing
Return funds for cancelled orders or disputes
Reward Distribution
Send staking rewards, referral bonuses, or prizes
Security First
Critical Security Requirements
Before implementing outbound payments, ensure you have:
- HSM or secure key storage - Never store hot wallet keys in plain text or environment variables
- Multi-signature for large amounts - Require multiple approvals above thresholds
- Rate limiting - Prevent rapid automated draining
- Audit logging - Track all payment operations
- Cold/hot wallet separation - Keep majority of funds offline
Architecture
Payment Sender Architecture
The payment sender sits between your application and the blockchain, managing the complexity of UTxO selection, transaction building, and confirmation tracking.
Step 1: Database Schema
-- Outbound payment requests
CREATE TABLE outbound_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reference_id TEXT NOT NULL, -- Your order ID, payout ID, etc.
reference_type TEXT NOT NULL, -- 'refund', 'payout', 'reward', etc.
destination_address TEXT NOT NULL,
amount_lovelace BIGINT NOT NULL,
fee_lovelace BIGINT,
status TEXT DEFAULT 'pending',
-- Status: pending → approved → processing → submitted → confirmed → (failed)
batch_id UUID,
tx_hash TEXT,
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
approved_at TIMESTAMPTZ,
submitted_at TIMESTAMPTZ,
confirmed_at TIMESTAMPTZ
);
CREATE INDEX idx_outbound_status ON outbound_payments(status);
CREATE INDEX idx_outbound_reference ON outbound_payments(reference_type, reference_id);
-- Payment batches (multiple payments in one transaction)
CREATE TABLE payment_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tx_hash TEXT,
tx_cbor TEXT, -- Signed transaction, for resubmission if needed
total_amount_lovelace BIGINT NOT NULL,
fee_lovelace BIGINT,
payment_count INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
submitted_at TIMESTAMPTZ,
confirmed_at TIMESTAMPTZ
);
-- Hot wallet UTxO tracking
CREATE TABLE hot_wallet_utxos (
tx_hash TEXT NOT NULL,
output_index INTEGER NOT NULL,
amount_lovelace BIGINT NOT NULL,
is_locked BOOLEAN DEFAULT FALSE, -- Locked for pending transaction
locked_by_batch UUID REFERENCES payment_batches(id),
is_spent BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (tx_hash, output_index)
);
CREATE INDEX idx_utxos_available ON hot_wallet_utxos(is_locked, is_spent);Step 2: Payment Validation
Validate requests before processing:
interface PaymentRequest {
referenceId: string
referenceType: string
destinationAddress: string
amountLovelace: bigint
}
class PaymentValidator {
private minPayment = BigInt(1_000_000) // 1 ADA minimum
private maxPayment = BigInt(100_000_000_000) // 100,000 ADA per payment
async validate(request: PaymentRequest): Promise<void> {
// 1. Validate amount
if (request.amountLovelace < this.minPayment) {
throw new Error('Minimum payment is 1 ADA')
}
if (request.amountLovelace > this.maxPayment) {
throw new Error('Maximum payment is 100,000 ADA')
}
// 2. Validate address format
if (!this.isValidAddress(request.destinationAddress)) {
throw new Error('Invalid destination address')
}
// 3. Check for duplicates
const existing = await db.query(
`SELECT id FROM outbound_payments
WHERE reference_type = $1 AND reference_id = $2
AND status NOT IN ('failed')`,
[request.referenceType, request.referenceId]
)
if (existing.rows.length > 0) {
throw new Error('Duplicate payment request')
}
// 4. Check hot wallet balance
const available = await this.getAvailableBalance()
if (available < request.amountLovelace + BigInt(2_000_000)) {
throw new Error('Insufficient hot wallet balance')
}
}
private isValidAddress(address: string): boolean {
// Validate Cardano address format
return address.startsWith('addr1') && address.length >= 58
}
private async getAvailableBalance(): Promise<bigint> {
const result = await db.query(
`SELECT COALESCE(SUM(amount_lovelace), 0) as total
FROM hot_wallet_utxos
WHERE is_locked = false AND is_spent = false`
)
return BigInt(result.rows[0].total)
}
}Step 3: Batch Processor
Combine multiple payments into efficient transactions:
class BatchProcessor {
private maxOutputsPerTx = 20 // Keep under protocol limits
private batchInterval = 60_000 // Process every minute
async processPendingPayments(): Promise<void> {
// Get approved payments ready for processing
const pending = await db.query(
`SELECT * FROM outbound_payments
WHERE status = 'approved'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED`
)
if (pending.rows.length === 0) return
// Group into batches
const batches = this.createBatches(pending.rows)
for (const batch of batches) {
try {
await this.processOneBatch(batch)
} catch (error) {
console.error('Batch failed:', error)
// Individual batch failure doesn't stop others
}
}
}
private createBatches(payments: Payment[]): Payment[][] {
const batches: Payment[][] = []
let current: Payment[] = []
for (const payment of payments) {
current.push(payment)
if (current.length >= this.maxOutputsPerTx) {
batches.push(current)
current = []
}
}
if (current.length > 0) {
batches.push(current)
}
return batches
}
private async processOneBatch(payments: Payment[]): Promise<void> {
const client = await db.connect()
try {
await client.query('BEGIN')
// 1. Calculate total needed
const totalAmount = payments.reduce(
(sum, p) => sum + BigInt(p.amount_lovelace),
BigInt(0)
)
// 2. Select and lock UTxOs
const utxos = await this.selectAndLockUtxos(
client,
totalAmount + BigInt(5_000_000) // Extra for fees + min UTxO
)
// 3. Create batch record
const batchResult = await client.query(
`INSERT INTO payment_batches (total_amount_lovelace, payment_count)
VALUES ($1, $2) RETURNING id`,
[totalAmount.toString(), payments.length]
)
const batchId = batchResult.rows[0].id
// 4. Update payment records
for (const payment of payments) {
await client.query(
`UPDATE outbound_payments
SET status = 'processing', batch_id = $1
WHERE id = $2`,
[batchId, payment.id]
)
}
// 5. Update UTxO locks
for (const utxo of utxos) {
await client.query(
`UPDATE hot_wallet_utxos
SET is_locked = true, locked_by_batch = $1
WHERE tx_hash = $2 AND output_index = $3`,
[batchId, utxo.txHash, utxo.index]
)
}
await client.query('COMMIT')
// 6. Build, sign, and submit (outside transaction)
await this.buildAndSubmit(batchId, utxos, payments)
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally {
client.release()
}
}
private async selectAndLockUtxos(
client: any,
amountNeeded: bigint
): Promise<Utxo[]> {
const result = await client.query(
`SELECT tx_hash, output_index, amount_lovelace
FROM hot_wallet_utxos
WHERE is_locked = false AND is_spent = false
ORDER BY amount_lovelace DESC
FOR UPDATE`
)
const selected: Utxo[] = []
let total = BigInt(0)
for (const row of result.rows) {
selected.push({
txHash: row.tx_hash,
index: row.output_index,
amount: BigInt(row.amount_lovelace)
})
total += BigInt(row.amount_lovelace)
if (total >= amountNeeded) break
}
if (total < amountNeeded) {
throw new Error(`Insufficient funds: need ${amountNeeded}, have ${total}`)
}
return selected
}
}Step 4: Transaction Submission
Submit with retry logic:
class TransactionSubmitter {
private maxRetries = 3
async submit(batchId: string, signedTxCbor: string): Promise<string> {
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const txId = await this.submitToApi(signedTxCbor)
// Update batch
await db.query(
`UPDATE payment_batches
SET tx_hash = $1, tx_cbor = $2, status = 'submitted', submitted_at = NOW()
WHERE id = $3`,
[txId, signedTxCbor, batchId]
)
// Update payments
await db.query(
`UPDATE outbound_payments
SET tx_hash = $1, status = 'submitted', submitted_at = NOW()
WHERE batch_id = $2`,
[txId, batchId]
)
console.log(`Batch ${batchId} submitted: ${txId}`)
return txId
} catch (error: any) {
console.error(`Attempt ${attempt} failed:`, error.message)
if (this.isRetryable(error) && attempt < this.maxRetries) {
await this.sleep(2000 * attempt) // Exponential backoff
continue
}
// Mark as failed
await this.markFailed(batchId, error.message)
throw error
}
}
throw new Error('Max retries exceeded')
}
private async submitToApi(txCbor: string): Promise<string> {
const response = await fetch('https://api.nacho.builders/v1/ogmios', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': process.env.NACHO_API_KEY!
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'submitTransaction',
params: { transaction: { cbor: txCbor } },
id: 'submit'
})
})
const data = await response.json()
if (data.error) {
throw new Error(data.error.message || data.error.code)
}
return data.result.transaction.id
}
private isRetryable(error: any): boolean {
const message = error.message || ''
// Retry network errors, not validation failures
return message.includes('ECONNRESET') ||
message.includes('timeout') ||
message.includes('fetch failed')
}
private async markFailed(batchId: string, errorMessage: string): Promise<void> {
await db.query(
`UPDATE payment_batches SET status = 'failed' WHERE id = $1`,
[batchId]
)
await db.query(
`UPDATE outbound_payments
SET status = 'failed', error_message = $1
WHERE batch_id = $2`,
[errorMessage, batchId]
)
// Unlock UTxOs for reuse
await db.query(
`UPDATE hot_wallet_utxos
SET is_locked = false, locked_by_batch = NULL
WHERE locked_by_batch = $1`,
[batchId]
)
}
}Step 5: Confirmation Tracking
Track confirmations using the same chain sync from Payment Monitoring:
// In your block processor, add tracking for outbound payments
async function trackOutboundConfirmations(block: Block): Promise<void> {
// Get submitted batches
const batches = await db.query(
`SELECT id, tx_hash FROM payment_batches WHERE status = 'submitted'`
)
for (const batch of batches.rows) {
// Check if our tx is in this block
const txInBlock = block.transactions?.some(tx => tx.id === batch.tx_hash)
if (txInBlock) {
// Calculate confirmations
const confirmations = block.height - (await getTxBlockHeight(batch.tx_hash))
if (confirmations >= 15) {
await markBatchConfirmed(batch.id)
}
}
}
}
async function markBatchConfirmed(batchId: string): Promise<void> {
await db.query(
`UPDATE payment_batches
SET status = 'confirmed', confirmed_at = NOW()
WHERE id = $1`,
[batchId]
)
await db.query(
`UPDATE outbound_payments
SET status = 'confirmed', confirmed_at = NOW()
WHERE batch_id = $1`,
[batchId]
)
// Mark UTxOs as spent (not just locked)
await db.query(
`UPDATE hot_wallet_utxos
SET is_spent = true
WHERE locked_by_batch = $1`,
[batchId]
)
console.log(`Batch ${batchId} confirmed`)
}Security Best Practices
Withdrawal Limits
const LIMITS = {
perPayment: BigInt(100_000_000_000), // 100,000 ADA max single payment
perHour: BigInt(500_000_000_000), // 500,000 ADA hourly limit
perDay: BigInt(2_000_000_000_000), // 2,000,000 ADA daily limit
}
async function checkLimits(amount: bigint): Promise<void> {
if (amount > LIMITS.perPayment) {
throw new Error('Exceeds per-payment limit - requires manual approval')
}
const hourlyTotal = await db.query(
`SELECT COALESCE(SUM(amount_lovelace), 0) as total
FROM outbound_payments
WHERE created_at > NOW() - INTERVAL '1 hour'
AND status NOT IN ('failed')`
)
if (BigInt(hourlyTotal.rows[0].total) + amount > LIMITS.perHour) {
throw new Error('Hourly limit reached')
}
const dailyTotal = await db.query(
`SELECT COALESCE(SUM(amount_lovelace), 0) as total
FROM outbound_payments
WHERE created_at > NOW() - INTERVAL '24 hours'
AND status NOT IN ('failed')`
)
if (BigInt(dailyTotal.rows[0].total) + amount > LIMITS.perDay) {
throw new Error('Daily limit reached')
}
}Hot Wallet Replenishment
async function checkHotWalletBalance(): Promise<void> {
const balance = await getAvailableBalance()
const lowThreshold = BigInt(50_000_000_000) // 50,000 ADA
const criticalThreshold = BigInt(10_000_000_000) // 10,000 ADA
if (balance < criticalThreshold) {
console.error('CRITICAL: Hot wallet critically low!')
await sendAlert('critical', `Hot wallet balance: ${balance / BigInt(1_000_000)} ADA`)
} else if (balance < lowThreshold) {
console.warn('WARNING: Hot wallet running low')
await sendAlert('warning', `Hot wallet balance: ${balance / BigInt(1_000_000)} ADA`)
}
}
// Run hourly
setInterval(checkHotWalletBalance, 60 * 60 * 1000)Refreshing Hot Wallet UTxOs
Keep your UTxO tracking in sync with chain state:
async function syncHotWalletUtxos(): Promise<void> {
// Query current UTxOs from chain
const response = await fetch('https://api.nacho.builders/v1/ogmios', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': process.env.NACHO_API_KEY!
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'queryLedgerState/utxo',
params: { addresses: [HOT_WALLET_ADDRESS] },
id: 'query-utxos'
})
})
const data = await response.json()
const chainUtxos = data.result
// Update database
const client = await db.connect()
try {
await client.query('BEGIN')
// Mark all as potentially spent
await client.query(
`UPDATE hot_wallet_utxos SET is_spent = true WHERE is_spent = false`
)
// Upsert current UTxOs
for (const utxo of chainUtxos) {
await client.query(
`INSERT INTO hot_wallet_utxos (tx_hash, output_index, amount_lovelace, is_spent)
VALUES ($1, $2, $3, false)
ON CONFLICT (tx_hash, output_index) DO UPDATE SET is_spent = false`,
[utxo.transaction.id, utxo.index, utxo.value.ada.lovelace]
)
}
await client.query('COMMIT')
} finally {
client.release()
}
}Next Steps
Now that you can send payments, learn to run your integration in production:
Next: Production Patterns
Was this page helpful?