Direct XRPL Integration—Building Custom Solutions | XRP for E-commerce | XRP Academy - XRP Academy
3 free lessons remaining this month

Free preview access resets monthly

Upgrade for Unlimited
Skip to main content
beginner60 min

Direct XRPL Integration—Building Custom Solutions

Learning Objectives

Set up xrpl.js for server-side payment processing

Generate and manage merchant wallet addresses

Monitor incoming payments using WebSocket subscriptions

Match payments to orders using destination tags

Handle edge cases including underpayments, overpayments, and failed transactions

  • Processing fee: 0.5-1% per transaction
  • Development time: Hours to days
  • Maintenance: Gateway handles updates
  • Volatility protection: Built-in
  • Processing fee: ~$0.0002 per transaction (network fee only)
  • Development time: Days to weeks
  • Maintenance: Your responsibility
  • Volatility protection: Build it yourself

The break-even calculation:

At 0.5% gateway fees, you save $5 per $1,000 in payments. If direct integration takes 40 development hours at $100/hour = $4,000, you break even at $800,000 in XRP payment volume.

  • High volume (hundreds of thousands in annual XRP payments)

  • Specific features gateways don't offer

  • Crypto-native business that holds XRP

  • Learning/educational purposes

  • Building a payment gateway yourself

  • Testing whether customers will use XRP

  • Low expected volume (<$100K/year)

  • No development resources

  • Need volatility protection (easier via gateway)


Node.js environment:

# Create project directory
mkdir xrp-payment-handler
cd xrp-payment-handler

# Initialize Node.js project
npm init -y

# Install xrpl.js
npm install xrpl

# Install additional utilities
npm install dotenv        # Environment variables
npm install express       # Web server for webhooks

Package.json:

{
  "name": "xrp-payment-handler",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "xrpl": "^3.0.0",
    "dotenv": "^16.0.0",
    "express": "^4.18.0"
  }
}

Available networks:

Network Purpose WebSocket URL
Mainnet Production wss://xrplcluster.com or wss://s1.ripple.com
Testnet Development wss://s.altnet.rippletest.net:51233
Devnet Experimental wss://s.devnet.rippletest.net:51233

Environment configuration (.env):

# Network
XRPL_NETWORK=testnet
XRPL_WS_URL=wss://s.altnet.rippletest.net:51233

# Merchant wallet (NEVER commit real keys!)
MERCHANT_ADDRESS=rYourMerchantAddress
MERCHANT_SECRET=sYourSecret  # Only needed for sending

# Application
PORT=3000

src/xrpl-client.js:

import xrpl from 'xrpl';
import dotenv from 'dotenv';

dotenv.config();

const NETWORK_URLS = {
  mainnet: 'wss://xrplcluster.com',
  testnet: 'wss://s.altnet.rippletest.net:51233',
  devnet: 'wss://s.devnet.rippletest.net:51233'
};

export async function createClient() {
  const network = process.env.XRPL_NETWORK || 'testnet';
  const url = process.env.XRPL_WS_URL || NETWORK_URLS[network];

const client = new xrpl.Client(url);

client.on('error', (error) => {
    console.error('XRPL Client Error:', error);
  });

client.on('disconnected', (code) => {
    console.log('Disconnected from XRPL:', code);
    // Implement reconnection logic here
  });

await client.connect();
  console.log(`Connected to ${network}: ${url}`);

return client;
}

Single wallet + destination tags (recommended):

// Your merchant receives all payments to one address
const MERCHANT_ADDRESS = process.env.MERCHANT_ADDRESS;

// Each order gets a unique destination tag
function generateDestinationTag(orderId) {
  // Option 1: Use order ID directly (if numeric and fits)
  if (typeof orderId === 'number' && orderId > 0 && orderId < 4294967295) {
    return orderId;
  }

// Option 2: Hash the order ID
  const crypto = await import('crypto');
  const hash = crypto.createHash('sha256').update(String(orderId)).digest();
  return hash.readUInt32BE(0);
}

For testnet development:

import xrpl from 'xrpl';

async function createTestWallet(client) {
  // Fund a new wallet from the testnet faucet
  const { wallet, balance } = await client.fundWallet();

console.log('New Wallet Created:');
  console.log('  Address:', wallet.address);
  console.log('  Secret:', wallet.seed);  // SAVE THIS SECURELY
  console.log('  Balance:', balance, 'XRP');

return wallet;
}

For production:

// NEVER generate production wallets in code
// Use a hardware wallet or secure key management service

// Load wallet from environment (signing key)
const wallet = xrpl.Wallet.fromSeed(process.env.MERCHANT_SECRET);

// Or just use the address for monitoring (no secret needed)
const MERCHANT_ADDRESS = process.env.MERCHANT_ADDRESS;

Require destination tags on your merchant account:

async function enableRequireDestTag(client, wallet) {
  const prepared = await client.autofill({
    TransactionType: 'AccountSet',
    Account: wallet.address,
    SetFlag: xrpl.AccountSetAsfFlags.asfRequireDest
  });

const signed = wallet.sign(prepared);
  const result = await client.submitAndWait(signed.tx_blob);

if (result.result.meta.TransactionResult === 'tesSUCCESS') {
    console.log('RequireDest flag enabled successfully');
    return true;
  } else {
    console.error('Failed to enable RequireDest:', result.result.meta.TransactionResult);
    return false;
  }
}

The most efficient way to monitor payments is via WebSocket subscription:

async function subscribeToPayments(client, merchantAddress, onPayment) {
  // Subscribe to account transactions
  await client.request({
    command: 'subscribe',
    accounts: [merchantAddress]
  });

console.log(`Subscribed to transactions for ${merchantAddress}`);

// Handle incoming transactions
  client.on('transaction', (event) => {
    // Only process validated transactions
    if (!event.validated) return;

const tx = event.transaction;

// Only process incoming payments (not outgoing)
    if (tx.TransactionType === 'Payment' && tx.Destination === merchantAddress) {
      const payment = {
        txHash: event.transaction.hash,
        from: tx.Account,
        to: tx.Destination,
        destinationTag: tx.DestinationTag || null,
        amountDrops: typeof tx.Amount === 'string' ? tx.Amount : null,
        amountXRP: typeof tx.Amount === 'string' ? xrpl.dropsToXrp(tx.Amount) : null,
        ledgerIndex: event.ledger_index,
        timestamp: event.transaction.date 
          ? xrpl.rippleTimeToISOTime(event.transaction.date) 
          : new Date().toISOString()
      };

// Only process XRP payments (not issued currencies)
      if (payment.amountDrops) {
        onPayment(payment);
      }
    }
  });
}

For simpler setups or when WebSocket isn't available:

async function pollForPayments(client, merchantAddress, lastLedger, onPayment) {
  const response = await client.request({
    command: 'account_tx',
    account: merchantAddress,
    ledger_index_min: lastLedger,
    ledger_index_max: -1,  // Current ledger
    limit: 100
  });

const payments = response.result.transactions
    .filter(tx => 
      tx.tx.TransactionType === 'Payment' &&
      tx.tx.Destination === merchantAddress &&
      tx.validated === true
    )
    .map(tx => ({
      txHash: tx.tx.hash,
      from: tx.tx.Account,
      destinationTag: tx.tx.DestinationTag || null,
      amountDrops: typeof tx.tx.Amount === 'string' ? tx.tx.Amount : null,
      amountXRP: typeof tx.tx.Amount === 'string' ? xrpl.dropsToXrp(tx.tx.Amount) : null,
      ledgerIndex: tx.tx.ledger_index
    }));

for (const payment of payments) {
    await onPayment(payment);
  }

// Return the latest ledger index for next poll
  return response.result.ledger_index_max;
}

// Polling loop
async function startPolling(client, merchantAddress, onPayment) {
  let lastLedger = -1;  // Start from current

setInterval(async () => {
    try {
      lastLedger = await pollForPayments(client, merchantAddress, lastLedger, onPayment);
    } catch (error) {
      console.error('Polling error:', error);
    }
  }, 5000);  // Poll every 5 seconds
}
Aspect WebSocket Polling
Latency Real-time (seconds) Depends on interval
Complexity Higher (connection management) Lower
Reliability Requires reconnection logic Simpler error handling
Resource usage Constant connection Periodic requests
Best for Production systems Simple implementations

src/payment-request.js:

import xrpl from 'xrpl';

const MERCHANT_ADDRESS = process.env.MERCHANT_ADDRESS;

export function createPaymentRequest(orderId, amountUSD, xrpPrice) {
  const destinationTag = generateDestinationTag(orderId);
  const amountXRP = (amountUSD / xrpPrice).toFixed(6);
  const amountDrops = xrpl.xrpToDrops(amountXRP);

// Generate X-address for convenience
  const xAddress = xrpl.classicAddressToXAddress(
    MERCHANT_ADDRESS,
    destinationTag,
    false  // mainnet
  );

const paymentRequest = {
    orderId,
    destinationTag,
    amountUSD,
    amountXRP,
    amountDrops,
    xrpPrice,
    merchantAddress: MERCHANT_ADDRESS,
    xAddress,
    expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),  // 15 minutes
    status: 'pending'
  };

return paymentRequest;
}

function generateDestinationTag(orderId) {
  const crypto = require('crypto');
  const hash = crypto.createHash('sha256').update(String(orderId)).digest();
  return hash.readUInt32BE(0);
}

src/order-matcher.js:

// In-memory store (use database in production)
const pendingOrders = new Map();

export function registerOrder(paymentRequest) {
  pendingOrders.set(paymentRequest.destinationTag, paymentRequest);
  console.log(`Order registered: ${paymentRequest.orderId} → Tag ${paymentRequest.destinationTag}`);
}

export function matchPayment(payment) {
  const { destinationTag, amountDrops, txHash } = payment;

// Find matching order
  const order = pendingOrders.get(destinationTag);

if (!order) {
    return {
      matched: false,
      reason: 'unknown_destination_tag',
      payment
    };
  }

// Check if order expired
  if (new Date(order.expiresAt) < new Date()) {
    return {
      matched: false,
      reason: 'order_expired',
      order,
      payment
    };
  }

// Check payment amount
  const expectedDrops = BigInt(order.amountDrops);
  const receivedDrops = BigInt(amountDrops);

if (receivedDrops < expectedDrops) {
    // Underpayment
    const shortfallDrops = expectedDrops - receivedDrops;
    return {
      matched: true,
      status: 'underpaid',
      order,
      payment,
      shortfallXRP: Number(shortfallDrops) / 1000000
    };
  }

if (receivedDrops > expectedDrops) {
    // Overpayment
    const excessDrops = receivedDrops - expectedDrops;
    return {
      matched: true,
      status: 'overpaid',
      order,
      payment,
      excessXRP: Number(excessDrops) / 1000000
    };
  }

// Exact payment
  return {
    matched: true,
    status: 'paid',
    order,
    payment
  };
}

export function completeOrder(destinationTag) {
  pendingOrders.delete(destinationTag);
}

src/payment-handler.js:

import { matchPayment, completeOrder } from './order-matcher.js';

export async function handlePayment(payment) {
  console.log('\n=== Payment Received ===');
  console.log('  TX Hash:', payment.txHash);
  console.log('  From:', payment.from);
  console.log('  Amount:', payment.amountXRP, 'XRP');
  console.log('  Destination Tag:', payment.destinationTag);

const result = matchPayment(payment);

if (!result.matched) {
    console.log('  Status: UNMATCHED');
    console.log('  Reason:', result.reason);
    await handleUnmatchedPayment(payment, result.reason);
    return;
  }

switch (result.status) {
    case 'paid':
      console.log('  Status: PAID ✓');
      console.log('  Order:', result.order.orderId);
      await fulfillOrder(result.order, payment);
      break;

case 'underpaid':
      console.log('  Status: UNDERPAID');
      console.log('  Shortfall:', result.shortfallXRP, 'XRP');
      await handleUnderpayment(result.order, payment, result.shortfallXRP);
      break;

case 'overpaid':
      console.log('  Status: OVERPAID');
      console.log('  Excess:', result.excessXRP, 'XRP');
      await fulfillOrder(result.order, payment);
      await scheduleRefund(result.order, payment, result.excessXRP);
      break;
  }
}

async function fulfillOrder(order, payment) {
  // Update order in your e-commerce system
  console.log(`Fulfilling order ${order.orderId}...`);

// Remove from pending
  completeOrder(order.destinationTag);

// TODO: Call your order management API
  // await yourEcommerceAPI.markOrderPaid(order.orderId, payment.txHash);
}

async function handleUnderpayment(order, payment, shortfall) {
  // Options:
  // 1. Wait for additional payment
  // 2. Notify customer
  // 3. Refund and cancel order

console.log(`Waiting for additional ${shortfall} XRP for order ${order.orderId}`);

// Update order to track partial payment
  order.partialPayments = order.partialPayments || [];
  order.partialPayments.push(payment);
  order.remainingXRP = shortfall;
}

async function handleUnmatchedPayment(payment, reason) {
  // Log for manual review
  console.log('Unmatched payment logged for review');

// TODO: Store in database for customer support
  // await db.unmatchedPayments.insert(payment);
}

async function scheduleRefund(order, payment, excessXRP) {
  console.log(`Scheduling refund of ${excessXRP} XRP to ${payment.from}`);

// TODO: Implement refund logic
  // Requires wallet secret to send transactions
}

src/index.js:

import express from 'express';
import xrpl from 'xrpl';
import dotenv from 'dotenv';
import { createClient } from './xrpl-client.js';
import { createPaymentRequest } from './payment-request.js';
import { registerOrder } from './order-matcher.js';
import { handlePayment } from './payment-handler.js';

dotenv.config();

const app = express();
app.use(express.json());

let xrplClient;
const MERCHANT_ADDRESS = process.env.MERCHANT_ADDRESS;

// Current XRP price (in production, fetch from exchange API)
let currentXRPPrice = 2.50;

// API: Create payment request
app.post('/api/payments', async (req, res) => {
  try {
    const { orderId, amountUSD } = req.body;

if (!orderId || !amountUSD) {
      return res.status(400).json({ error: 'orderId and amountUSD required' });
    }

const paymentRequest = createPaymentRequest(orderId, amountUSD, currentXRPPrice);
    registerOrder(paymentRequest);

res.json({
      success: true,
      payment: {
        orderId: paymentRequest.orderId,
        amountXRP: paymentRequest.amountXRP,
        amountUSD: paymentRequest.amountUSD,
        address: paymentRequest.merchantAddress,
        destinationTag: paymentRequest.destinationTag,
        xAddress: paymentRequest.xAddress,
        expiresAt: paymentRequest.expiresAt
      }
    });
  } catch (error) {
    console.error('Payment creation error:', error);
    res.status(500).json({ error: 'Failed to create payment request' });
  }
});

// API: Check payment status
app.get('/api/payments/:orderId', async (req, res) => {
  // TODO: Implement order lookup
  res.json({ status: 'pending' });
});

// Initialize XRPL connection and start monitoring
async function initialize() {
  xrplClient = await createClient();

// Subscribe to payments
  await xrplClient.request({
    command: 'subscribe',
    accounts: [MERCHANT_ADDRESS]
  });

console.log(`Monitoring payments to ${MERCHANT_ADDRESS}`);

// Handle incoming transactions
  xrplClient.on('transaction', (event) => {
    if (!event.validated) return;

const tx = event.transaction;
    if (tx.TransactionType === 'Payment' && tx.Destination === MERCHANT_ADDRESS) {
      const payment = {
        txHash: tx.hash,
        from: tx.Account,
        to: tx.Destination,
        destinationTag: tx.DestinationTag || null,
        amountDrops: typeof tx.Amount === 'string' ? tx.Amount : null,
        amountXRP: typeof tx.Amount === 'string' ? xrpl.dropsToXrp(tx.Amount) : null,
        ledgerIndex: event.ledger_index
      };

if (payment.amountDrops) {
        handlePayment(payment);
      }
    }
  });

// Start HTTP server
  const PORT = process.env.PORT || 3000;
  app.listen(PORT, () => {
    console.log(`Payment API listening on port ${PORT}`);
  });
}

// Handle graceful shutdown
process.on('SIGINT', async () => {
  console.log('\nShutting down...');
  if (xrplClient) {
    await xrplClient.disconnect();
  }
  process.exit(0);
});

initialize().catch(console.error);
# Development (testnet)
npm run dev

Output:

Connected to testnet: wss://s.altnet.rippletest.net:51233

Monitoring payments to rYourTestnetAddress

Payment API listening on port 3000


Create a payment request:

curl -X POST http://localhost:3000/api/payments \
  -H "Content-Type: application/json" \
  -d '{"orderId": "ORDER-001", "amountUSD": 25.00}'

Response:

{
  "success": true,
  "payment": {
    "orderId": "ORDER-001",
    "amountXRP": "10.000000",
    "amountUSD": 25,
    "address": "rYourTestnetAddress",
    "destinationTag": 1234567890,
    "xAddress": "T7hJ3kPq5...",
    "expiresAt": "2025-01-15T12:30:00.000Z"
  }
}

Send payment via XRPL (testnet):

Use any XRP wallet to send the specified amount to the address with the destination tag.


Reconnection logic:

class ReliableXRPLClient {
  constructor(url, options = {}) {
    this.url = url;
    this.reconnectDelay = options.reconnectDelay || 5000;
    this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
    this.client = null;
    this.reconnectAttempts = 0;
    this.subscriptions = [];
  }

async connect() {
    this.client = new xrpl.Client(this.url);

this.client.on('disconnected', () => {
      console.log('XRPL connection lost, attempting reconnect...');
      this.reconnect();
    });

this.client.on('error', (error) => {
      console.error('XRPL error:', error);
    });

await this.client.connect();
    this.reconnectAttempts = 0;

// Restore subscriptions after reconnect
    for (const sub of this.subscriptions) {
      await this.client.request(sub);
    }
  }

async reconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('Max reconnection attempts reached');
      process.exit(1);
    }

this.reconnectAttempts++;

setTimeout(async () => {
      try {
        await this.connect();
        console.log('Reconnected to XRPL');
      } catch (error) {
        console.error('Reconnection failed:', error);
        this.reconnect();
      }
    }, this.reconnectDelay * this.reconnectAttempts);
  }

async subscribe(request) {
    this.subscriptions.push(request);
    return this.client.request(request);
  }
}

Replace in-memory storage with database:

// Example with PostgreSQL
import pg from 'pg';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL
});

export async function saveOrder(paymentRequest) {
  await pool.query(`
    INSERT INTO orders (order_id, destination_tag, amount_xrp, amount_usd, expires_at, status)
    VALUES ($1, $2, $3, $4, $5, 'pending')
  `, [
    paymentRequest.orderId,
    paymentRequest.destinationTag,
    paymentRequest.amountXRP,
    paymentRequest.amountUSD,
    paymentRequest.expiresAt
  ]);
}

export async function findOrderByTag(destinationTag) {
  const result = await pool.query(
    'SELECT * FROM orders WHERE destination_tag = $1',
    [destinationTag]
  );
  return result.rows[0];
}

export async function markOrderPaid(orderId, txHash) {
  await pool.query(`
    UPDATE orders 
    SET status = 'paid', tx_hash = $2, paid_at = NOW()
    WHERE order_id = $1
  `, [orderId, txHash]);
}
Concern Mitigation
Private key exposure Never store merchant secret in code; use environment variables or key management service
Transaction validation Always verify validated: true before processing payments
Amount verification Check exact amounts; beware of partial payments
Replay attacks Track processed transaction hashes to prevent double-crediting
Rate limiting Implement rate limits on API endpoints

Transaction hash tracking:

const processedTransactions = new Set();

function processPayment(payment) {
  // Prevent double-processing
  if (processedTransactions.has(payment.txHash)) {
    console.log('Transaction already processed:', payment.txHash);
    return;
  }

processedTransactions.add(payment.txHash);

// Process the payment...
}

Essential monitoring:

// Payment processing metrics
const metrics = {
  paymentsReceived: 0,
  paymentsMatched: 0,
  paymentsUnmatched: 0,
  underpayments: 0,
  overpayments: 0,
  connectionDrops: 0
};

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({
    status: xrplClient?.isConnected() ? 'healthy' : 'degraded',
    metrics,
    uptime: process.uptime()
  });
});

// Alert on critical issues
function alert(message) {
  console.error('[ALERT]', message);
  // TODO: Send to Slack, PagerDuty, etc.
}

xrpl.js is mature and well-documented. The official JavaScript library is actively maintained and used in production by major projects.

WebSocket subscriptions work reliably. Real-time payment monitoring is a standard pattern used by exchanges and payment processors.

Destination tag matching is standard practice. This is how every major XRP service handles payment routing.

⚠️ Operational complexity. Running your own payment infrastructure requires 24/7 monitoring, reconnection logic, and incident response capabilities.

⚠️ Volatility handling. Without a gateway's instant conversion, you bear the risk of XRP price changes between checkout and settlement.

⚠️ Regulatory considerations. Handling crypto payments directly may have different compliance implications than using a regulated gateway.

Direct XRPL integration is powerful but operationally demanding. It eliminates processing fees and provides full control, but requires significant development investment and ongoing maintenance. For most e-commerce businesses, this is overkill—start with a payment gateway, measure your actual XRP volume, and consider direct integration only if the cost savings justify the operational burden.


Assignment: Build a functional XRP payment handler using direct XRPL integration.

Requirements:

Part 1: Core Implementation

  • Connects to XRPL Testnet
  • Generates payment requests with unique destination tags
  • Monitors for incoming payments via WebSocket
  • Matches payments to orders
  • Handles payment confirmation

Part 2: API Endpoints

  • POST /payments - Create payment request
  • GET /payments/:orderId - Check payment status

Part 3: Edge Case Handling

  • Payment with unknown destination tag
  • Underpayment (partial amount)
  • Overpayment (excess amount)
  • Payment after order expiration

Part 4: Testing

  • Create payment request via API
  • Send test payment on Testnet
  • Show payment detection and order matching
  • Demonstrate at least one edge case

Part 5: Production Readiness Assessment

  • What persistence layer?

  • What monitoring?

  • What security considerations?

  • What reliability improvements?

  • Core functionality (30%)

  • Edge case handling (25%)

  • Code quality (20%)

  • Testing documentation (15%)

  • Production assessment (10%)

Time investment: 6-8 hours
Deliverable format: GitHub repository with README, code, and documentation


Knowledge Check

Question 1 of 5

When monitoring for payments using xrpl.js, which method provides real-time notifications?

For Next Lesson:
Lesson 9 covers Volatility Management—strategies for handling XRP price fluctuations whether using gateways or direct integration.


End of Lesson 8

Total words: ~5,500
Estimated completion time: 60 minutes reading + 6-8 hours for deliverable


What This Lesson Accomplishes:

  1. Provides working code. Students can actually build a payment handler, not just read about concepts.

  2. Emphasizes operational reality. Direct integration isn't just development—it's ongoing operations.

  3. Covers production concerns. Connection reliability, security, monitoring are all addressed.

  4. Maintains realistic expectations. The lesson is clear that this is overkill for most use cases.

  5. Builds on Lesson 6 concepts. Destination tags, X-addresses, and payment architecture from Lesson 6 are now implemented in code.

Lesson 8 → Lesson 9 Transition:

Both gateway users (Lesson 7) and direct integration users (Lesson 8) need volatility management strategies. Lesson 9 covers this universal concern.

  • All code samples are functional but simplified for learning
  • Production implementations need error handling, logging, and testing
  • Database examples show patterns, not production-ready implementations
  • Security considerations are highlighted but not exhaustively covered

Key Takeaways

1

Direct integration eliminates gateway fees

but requires significant development and operational investment. Break-even is typically at hundreds of thousands in annual volume.

2

Use xrpl.js for JavaScript/Node.js.

It's the official library with comprehensive documentation and active maintenance.

3

WebSocket subscriptions are the standard approach

for real-time payment monitoring. Polling works for simpler implementations.

4

Destination tag matching is essential.

Generate unique tags per order, require them on your account, and handle edge cases (unknown tags, expired orders).

5

Production requires reliability engineering.

Connection management, database persistence, transaction tracking, and monitoring are all necessary for a production system. ---