Advanced25 min read
Edit on GitHub

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:

  1. Queues and validates payment requests
  2. Batches multiple payments into single transactions
  3. Manages hot wallet UTxOs efficiently
  4. Tracks transaction confirmations
  5. 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:

  1. HSM or secure key storage - Never store hot wallet keys in plain text or environment variables
  2. Multi-signature for large amounts - Require multiple approvals above thresholds
  3. Rate limiting - Prevent rapid automated draining
  4. Audit logging - Track all payment operations
  5. Cold/hot wallet separation - Keep majority of funds offline

Architecture

Payment Sender Architecture

Outbound Payment SystemrequestUTxOssubmit
Your Application
Payment Queue
PostgreSQL
Batch Processor
Hot Wallet Manager
Nacho API

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?