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 requestGET /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 5When monitoring for payments using xrpl.js, which method provides real-time notifications?
- Official docs: https://js.xrpl.org/
- GitHub: https://github.com/XRPLF/xrpl.js
- Get Started with JavaScript: https://xrpl.org/docs/tutorials/javascript/get-started
- Monitor Payments with WebSocket: https://xrpl.org/docs/tutorials/http-websocket-apis/build-apps/monitor-incoming-payments-with-websocket
- Subscribe method: https://xrpl.org/docs/references/http-websocket-apis/public-api-methods/subscription-methods/subscribe
- Payment transaction: https://xrpl.org/docs/references/protocol/transactions/types/payment
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:
Provides working code. Students can actually build a payment handler, not just read about concepts.
Emphasizes operational reality. Direct integration isn't just development—it's ongoing operations.
Covers production concerns. Connection reliability, security, monitoring are all addressed.
Maintains realistic expectations. The lesson is clear that this is overkill for most use cases.
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
Direct integration eliminates gateway fees
but requires significant development and operational investment. Break-even is typically at hundreds of thousands in annual volume.
Use xrpl.js for JavaScript/Node.js.
It's the official library with comprehensive documentation and active maintenance.
WebSocket subscriptions are the standard approach
for real-time payment monitoring. Polling works for simpler implementations.
Destination tag matching is essential.
Generate unique tags per order, require them on your account, and handle edge cases (unknown tags, expired orders).
Production requires reliability engineering.
Connection management, database persistence, transaction tracking, and monitoring are all necessary for a production system. ---