Payment Integration Patterns - From Simple to Complex | XRPL APIs & Integration | XRP Academy - XRP Academy
3 free lessons remaining this month

Free preview access resets monthly

Upgrade for Unlimited
Skip to main content
intermediate55 min

Payment Integration Patterns - From Simple to Complex

Learning Objectives

Implement XRP payments with correct drops conversion and validation

Handle destination tags correctly for exchange and custodial integration

Execute issued currency payments with trust line verification

Use pathfinding for cross-currency payments with appropriate limits

Protect against the partial payment vulnerability in all payment handling

Payments seem simple—move value from A to B. On XRPL, this basic operation has many variations:

PAYMENT SCENARIOS:

Simple XRP → Direct transfer of native asset
+ Destination Tag → Required by exchanges/custodians
+ Issued Currency → Requires trust lines
+ Cross-Currency → Requires pathfinding
+ Partial Payments → The security trap

Each layer adds complexity and potential for bugs.

This lesson builds from simple to complex, with security considerations throughout.


The simplest payment: send XRP from one account to another.

const payment = {
  TransactionType: 'Payment',
  Account: wallet.address,
  Destination: 'rRecipient...',
  Amount: xrpl.xrpToDrops('25')  // 25 XRP = "25000000" drops
}

const result = await client.submitAndWait(payment, { wallet })
```

XRP is stored and transmitted in "drops"—the smallest unit:

1 XRP = 1,000,000 drops

CONVERSIONS:
xrpl.xrpToDrops('25')    → '25000000'
xrpl.xrpToDrops('0.001') → '1000'
xrpl.xrpToDrops('0.000001') → '1'  (minimum)

xrpl.dropsToXrp('25000000') → '25'
xrpl.dropsToXrp('1000')     → '0.001'

Common Mistake:

// WRONG: This sends 25 drops, not 25 XRP!
Amount: '25'

// RIGHT: Convert XRP to drops
Amount: xrpl.xrpToDrops('25')  // '25000000'

// Also RIGHT: Already in drops
Amount: '25000000'
MINIMUM AMOUNTS:

To existing account:   1 drop (0.000001 XRP)
To create new account: reserve_base (currently 10 XRP)

If destination doesn't exist and amount < reserve:
Result: tecNO_DST_INSUF_XRP

Checking If Account Exists:

async function getMinimumPayment(client, destination) {
  try {
    await client.request({
      command: 'account_info',
      account: destination,
      ledger_index: 'validated'
    })
    return 1  // Account exists, minimum is 1 drop
  } catch (error) {
    if (error.data?.error === 'actNotFound') {
      // Account doesn't exist, need reserve to create
      const serverInfo = await client.request({ command: 'server_info' })
      const reserve = serverInfo.result.info.validated_ledger.reserve_base_xrp
      return reserve * 1_000_000  // Convert XRP to drops
    }
    throw error
  }
}
async function validateXRPPayment(client, sender, destination, amountDrops) {
  const errors = []

// Validate addresses
if (!xrpl.isValidAddress(sender)) {
errors.push('Invalid sender address')
}
if (!xrpl.isValidAddress(destination)) {
errors.push('Invalid destination address')
}
if (sender === destination) {
errors.push('Cannot send to self')
}

// Validate amount
const amount = parseInt(amountDrops)
if (isNaN(amount) || amount <= 0) {
errors.push('Invalid amount')
}

// Check sender balance
try {
const senderInfo = await client.request({
command: 'account_info',
account: sender,
ledger_index: 'validated'
})

const balance = parseInt(senderInfo.result.account_data.Balance)
const ownerCount = senderInfo.result.account_data.OwnerCount

const serverInfo = await client.request({ command: 'server_info' })
const reserves = serverInfo.result.info.validated_ledger
const reserved = (reserves.reserve_base_xrp + ownerCount * reserves.reserve_inc_xrp) * 1_000_000

const available = balance - reserved - 12 // Leave room for fee

if (amount > available) {
errors.push(Insufficient balance. Available: ${available} drops)
}
} catch (error) {
errors.push(Cannot verify sender account: ${error.message})
}

// Check minimum for destination
const minimum = await getMinimumPayment(client, destination)
if (amount < minimum) {
errors.push(Amount below minimum (${minimum} drops for this destination))
}

return errors
}
```


Destination tags are 32-bit integers that identify the beneficiary at a shared address. Exchanges and custodians use one XRP address for all customers, distinguished by tags.

WITHOUT DESTINATION TAGS:
User A → Exchange Address → Which customer?
User B → Exchange Address → Which customer?

WITH DESTINATION TAGS:
User A → Exchange Address (tag: 12345) → Credit User A
User B → Exchange Address (tag: 67890) → Credit User B
```

Some accounts have the RequireDestTag flag set:

async function requiresDestinationTag(client, address) {
  const info = await client.request({
    command: 'account_info',
    account: address,
    ledger_index: 'validated'
  })

const flags = info.result.account_data.Flags
  const lsfRequireDestTag = 0x00020000  // 131072

return (flags & lsfRequireDestTag) !== 0
}

// Usage
if (await requiresDestinationTag(client, destination)) {
  if (!destinationTag) {
    throw new Error('Destination requires a destination tag')
  }
}
const payment = {
  TransactionType: 'Payment',
  Account: wallet.address,
  Destination: 'rExchange...',
  Amount: xrpl.xrpToDrops('100'),
  DestinationTag: 12345  // CRITICAL: Integer, not string
}

Source tags work like destination tags but identify the sender. Useful for refund routing:

const payment = {
  TransactionType: 'Payment',
  Account: wallet.address,
  Destination: 'rMerchant...',
  Amount: xrpl.xrpToDrops('50'),
  SourceTag: 98765  // Your reference number
}
async function sendPaymentWithTags(client, wallet, params) {
  const { destination, amount, destinationTag, sourceTag, invoiceId } = params

// Validate destination tag requirement
if (await requiresDestinationTag(client, destination)) {
if (destinationTag === undefined || destinationTag === null) {
throw new Error('Destination requires a destination tag')
}
}

const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: destination,
Amount: xrpl.xrpToDrops(amount)
}

if (destinationTag !== undefined) {
payment.DestinationTag = parseInt(destinationTag)
}

if (sourceTag !== undefined) {
payment.SourceTag = parseInt(sourceTag)
}

if (invoiceId) {
// 256-bit hash for reference (optional)
payment.InvoiceID = invoiceId
}

return client.submitAndWait(payment, { wallet })
}
```


XRPL supports tokens issued by any account. These require trust lines:

ISSUED CURRENCY STRUCTURE:
{
  "currency": "USD",           // 3-char code or 40-hex
  "issuer": "rIssuerAddress",  // Who issued it
  "value": "100.00"            // Amount as string
}

vs XRP (native):
"25000000"  // Just drops as string

Before receiving an issued currency, an account must create a trust line:

Trust Line = "I trust [issuer] for up to [limit] of [currency]"

Without trust line: tecNO_DST (destination can't receive)
With trust line:    Payment succeeds
async function hasTrustLine(client, account, currency, issuer) {
  const response = await client.request({
    command: 'account_lines',
    account: account,
    peer: issuer,
    ledger_index: 'validated'
  })

const line = response.result.lines.find(
l => l.currency === currency && l.account === issuer
)

if (!line) return false

return {
exists: true,
balance: parseFloat(line.balance),
limit: parseFloat(line.limit)
}
}
```

const payment = {
  TransactionType: 'Payment',
  Account: wallet.address,
  Destination: 'rRecipient...',
  Amount: {
    currency: 'USD',
    issuer: 'rBitstamp...',
    value: '100.00'  // String, not number
  }
}

const result = await client.submitAndWait(payment, { wallet })
```

When paying with issued currencies, specify the maximum you're willing to send:

const payment = {
  TransactionType: 'Payment',
  Account: wallet.address,
  Destination: 'rRecipient...',
  Amount: {
    currency: 'USD',
    issuer: 'rIssuer...',
    value: '100.00'
  },
  SendMax: {
    currency: 'USD',
    issuer: 'rIssuer...',
    value: '100.50'  // Allow small overage for fees/rounding
  }
}

XRPL can convert currencies atomically using the DEX:

You have: EUR
They want: JPY

Path: EUR → XRP → JPY (or EUR → USD → JPY, etc.)

The payment finds the best path and executes atomically.

Before cross-currency payment, find available paths:

const paths = await client.request({
  command: 'ripple_path_find',
  source_account: wallet.address,
  destination_account: 'rRecipient...',
  destination_amount: {
    currency: 'JPY',
    issuer: 'rJPYIssuer...',
    value: '10000'
  },
  source_currencies: [
    { currency: 'EUR', issuer: 'rEURIssuer...' },
    { currency: 'XRP' }
  ]
})

console.log(paths.result.alternatives)
// [
//   {
//     paths_computed: [...],
//     source_amount: { currency: 'EUR', value: '78.50', ... }
//   },
//   ...
// ]
const pathResult = paths.result.alternatives[0]

const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: {
currency: 'JPY',
issuer: 'rJPYIssuer...',
value: '10000'
},
SendMax: pathResult.source_amount, // Maximum EUR to spend
Paths: pathResult.paths_computed // Use found paths
}

const result = await client.submitAndWait(payment, { wallet })
```

CONSIDERATIONS:

1. Slippage: Prices change between pathfind and execution

1. Liquidity: Paths may not have enough depth

1. Path Expiry: Paths can become invalid

1. Fees: Transfer fees may apply to issued currencies

---

The tfPartialPayment flag allows a payment to deliver less than the Amount field specifies:

const payment = {
  TransactionType: 'Payment',
  Account: wallet.address,
  Destination: 'rRecipient...',
  Amount: xrpl.xrpToDrops('1000'),  // Requested: 1000 XRP
  Flags: xrpl.PaymentFlags.tfPartialPayment  // Flag: 0x00020000
}

// This payment might deliver only 100 XRP and still succeed!
```

Legitimate use: Cross-currency payments where exact conversion is uncertain.

Scenario: Pay exactly 1000 JPY, spending up to 10 EUR
  • If 10 EUR converts to 1050 JPY, transaction fails (too much)
  • You get 1000 JPY, spend only ~9.52 EUR
  • Flexible for the sender

THE PROBLEM: If you check the Amount field instead of delivered_amount, you can be tricked:

// VULNERABLE CODE - DO NOT USE
function processPayment(tx) {
  const amount = tx.Amount  // WRONG! Could be manipulated
  creditUser(tx.Destination, amount)
}

// Attacker sends:
// Amount: 1000 XRP
// Flags: tfPartialPayment
// delivered_amount: 1 XRP
//
// Your code credits 1000 XRP, attacker sent 1 XRP
```

Real-World Impact: Multiple exchanges have lost millions of dollars to this exploit.

ALWAYS use delivered_amount:

function safeGetDeliveredAmount(tx) {
  // For successful payments, meta.delivered_amount is authoritative
  if (tx.meta?.delivered_amount) {
    return tx.meta.delivered_amount
  }

// For pre-2014 transactions or edge cases
  // Only trust Amount if partial payment flag is NOT set
  const flags = tx.Flags || 0
  const tfPartialPayment = 0x00020000

if ((flags & tfPartialPayment) === 0) {
    return tx.Amount  // Safe if no partial payment flag
  }

// Partial payment without delivered_amount - reject
  throw new Error('Cannot determine delivered amount for partial payment')
}

// Complete safe payment processing
function processIncomingPayment(tx) {
  // Verify it's a Payment
  if (tx.TransactionType !== 'Payment') return null

// Verify it succeeded
  if (tx.meta?.TransactionResult !== 'tesSUCCESS') return null

// Get the ACTUAL delivered amount
  const delivered = safeGetDeliveredAmount(tx)

// Now safe to process
  return {
    destination: tx.Destination,
    destinationTag: tx.DestinationTag,
    amount: delivered,
    hash: tx.hash,
    ledger: tx.ledger_index
  }
}
function isPartialPayment(tx) {
  const flags = tx.Flags || 0
  const tfPartialPayment = 0x00020000
  return (flags & tfPartialPayment) !== 0
}

function analyzePayment(tx) {
const isPartial = isPartialPayment(tx)
const requestedAmount = tx.Amount
const deliveredAmount = tx.meta?.delivered_amount

return {
isPartialPayment: isPartial,
requested: requestedAmount,
delivered: deliveredAmount,
fullyDelivered: !isPartial ||
JSON.stringify(requestedAmount) === JSON.stringify(deliveredAmount)
}
}
```

Many systems simply reject partial payments entirely:

function validateDeposit(tx) {
  if (isPartialPayment(tx)) {
    console.log('Rejecting partial payment:', tx.hash)
    return {
      valid: false,
      reason: 'Partial payments not accepted'
    }
  }

// Safe to use Amount field if not partial payment
  return {
    valid: true,
    amount: tx.Amount
  }
}

class PaymentGateway {
  constructor(client, wallet) {
    this.client = client
    this.wallet = wallet
  }

async sendXRP(destination, amountXRP, options = {}) {
// Validate
const amountDrops = xrpl.xrpToDrops(amountXRP)
const errors = await validateXRPPayment(
this.client,
this.wallet.address,
destination,
amountDrops
)

if (errors.length > 0) {
throw new Error(Validation failed: ${errors.join(', ')})
}

// Check destination tag requirement
if (await requiresDestinationTag(this.client, destination)) {
if (!options.destinationTag) {
throw new Error('Destination requires a tag')
}
}

// Build payment
const payment = {
TransactionType: 'Payment',
Account: this.wallet.address,
Destination: destination,
Amount: amountDrops
}

if (options.destinationTag) {
payment.DestinationTag = parseInt(options.destinationTag)
}

if (options.sourceTag) {
payment.SourceTag = parseInt(options.sourceTag)
}

if (options.memo) {
payment.Memos = [{
Memo: {
MemoData: Buffer.from(options.memo).toString('hex').toUpperCase()
}
}]
}

// Submit and wait
const result = await this.client.submitAndWait(payment, {
wallet: this.wallet
})

// Verify success
if (result.result.meta.TransactionResult !== 'tesSUCCESS') {
throw new Error(Payment failed: ${result.result.meta.TransactionResult})
}

return {
success: true,
hash: result.result.hash,
deliveredAmount: result.result.meta.delivered_amount,
ledger: result.result.ledger_index,
fee: result.result.Fee
}
}

processIncomingPayment(tx) {
// Must be a Payment
if (tx.TransactionType !== 'Payment') {
return null
}

// Must have succeeded
if (tx.meta?.TransactionResult !== 'tesSUCCESS') {
return null
}

// Reject partial payments
if (isPartialPayment(tx)) {
console.warn(Rejecting partial payment: ${tx.hash})
return {
valid: false,
reason: 'Partial payment rejected',
hash: tx.hash
}
}

// Get amount (safe since we rejected partial payments)
const amount = tx.meta?.delivered_amount || tx.Amount

return {
valid: true,
from: tx.Account,
to: tx.Destination,
destinationTag: tx.DestinationTag,
sourceTag: tx.SourceTag,
amount: amount,
hash: tx.hash,
ledger: tx.ledger_index,
timestamp: tx.date
}
}
}
```


Drops conversion is essential: Getting this wrong sends wrong amounts

Destination tags are critical for custodians: Missing tags lose customer funds

Partial payment vulnerability is real: Exchanges have lost real money

delivered_amount is the only safe field: Never trust Amount for deposits

⚠️ Pathfinding reliability: Path quality varies; may need multiple attempts

⚠️ Cross-currency slippage: Markets move; SendMax margins need tuning

⚠️ Partial payment policy: Some legitimate use cases exist; blanket rejection is safe but limiting

🔴 Using Amount instead of delivered_amount: Potential for significant financial loss

🔴 Missing destination tags: Funds credited to wrong user or lost

🔴 Cross-currency without SendMax limits: Could spend more than intended

🔴 Trusting pathfinding results indefinitely: Paths expire; execute quickly

Payment integration looks simple but has security implications that have cost real money. The partial payment vulnerability is the most critical—using delivered_amount instead of Amount is non-negotiable. Destination tags are the second most common integration issue, causing support headaches. Get these two things right, and most payment integrations will work correctly.


Assignment: Build a complete payment handling module that safely processes both outgoing and incoming payments.

Requirements:

Part 1: Outgoing Payments (40%)

  • XRP payment with validation
  • Destination tag handling (detect requirement, include when provided)
  • Balance and reserve checking before submission
  • Error handling with clear messages

Part 2: Incoming Payment Processing (40%)

  • Safe payment extraction from transaction
  • Partial payment detection and rejection
  • Correct delivered_amount usage
  • Support for destination tag routing

Part 3: Testing (20%)

  • Send XRP payment successfully

  • Send payment with destination tag

  • Process incoming payment correctly

  • Detect and reject partial payment (simulate if possible)

  • Correct delivered_amount handling: 30%

  • Destination tag handling: 20%

  • Outgoing payment validation: 20%

  • Error handling and edge cases: 20%

  • Code quality: 10%

Time Investment: 3-4 hours

Submission: Code module with tests and documentation

Security Note: This code handles money. Review carefully. The partial payment check is the most critical component.


1. Partial Payment Security (Tests Critical Understanding):

An incoming payment has Amount: "1000000000" (1000 XRP) but delivered_amount: "1000000" (1 XRP). How much should you credit the user?

A) 1000 XRP - that's what the Amount says
B) 1 XRP - use delivered_amount
C) 500.5 XRP - average of the two
D) 0 XRP - reject due to discrepancy

Correct Answer: B

Explanation: ALWAYS use delivered_amount for incoming payments. The Amount field can be manipulated with partial payments. The actual delivered value was 1 XRP, and that's what should be credited. Option A is the vulnerability that has lost exchanges millions. Option D is overly conservative—legitimate partial payments exist.


2. Destination Tags (Tests Application):

You're building an exchange. A user complains their deposit wasn't credited. Investigation shows a payment to your address with no destination tag. What happened?

A) The payment failed
B) The funds were received but you don't know which user to credit
C) The user sent to the wrong address
D) This is impossible if your address requires destination tags

Correct Answer: B

Explanation: If your exchange address doesn't have the RequireDestTag flag set, payments without tags will succeed. You receive the funds but have no way to know which customer deposited. This is why exchanges should set RequireDestTag and why users must include their assigned tag. Option D would be true only if RequireDestTag is set.


3. Drops Conversion (Tests Knowledge):

A user wants to send 25 XRP. Which Amount value is correct?

A) "25"
B) "25000000"
C) 25000000
D) Both B and C

Correct Answer: B

Explanation: Amounts in XRPL are always strings representing drops. 25 XRP = 25,000,000 drops = "25000000". Option A would send 25 drops (0.000025 XRP). Option C is a number, but amounts should be strings. While some libraries may accept numbers, the canonical format is string.


4. Cross-Currency Payments (Tests Understanding):

Why is SendMax important for cross-currency payments?

A) It speeds up the transaction
B) It limits the maximum amount you're willing to spend for the conversion
C) It's required by the XRPL protocol
D) It sets the exchange rate

Correct Answer: B

Explanation: SendMax sets the maximum amount of source currency you'll spend to deliver the destination amount. Without it (or with too high a value), price slippage could result in spending more than expected. It's a protection against unfavorable exchange rates, not a protocol requirement or exchange rate setter.


5. Trust Lines (Tests Comprehension):

You try to send USD (issued by Bitstamp) to an account that has no trust line to Bitstamp. What happens?

A) The payment creates a trust line automatically
B) The payment fails with tecNO_LINE
C) The payment converts USD to XRP automatically
D) The payment succeeds but the recipient can't use the USD

Correct Answer: B (Note: The actual result code may vary; tecPATH_DRY or similar)

Explanation: Accounts must explicitly create trust lines before receiving issued currencies. Without a trust line, the recipient cannot hold that currency, and the payment fails. Trust lines are never created automatically by payments—this is a security feature preventing unwanted token receipt.


For Next Lesson:
Lesson 8 covers subscription streams and event-driven architecture—building real-time payment monitors, handling account notifications, and designing systems that respond to ledger events as they happen.


End of Lesson 7

Total words: ~4,900
Estimated completion time: 55 minutes reading + 3-4 hours for deliverable

Key Takeaways

1

Always convert XRP to drops:

`xrpl.xrpToDrops()` prevents orders-of-magnitude errors. Never send raw numbers assuming they're XRP.

2

Check and require destination tags:

Many accounts require tags. Sending without one when required loses funds or creates support issues.

3

NEVER trust Amount for incoming payments:

Always use `delivered_amount`. The partial payment vulnerability has cost exchanges millions.

4

Validate before submission:

Check balances, destination existence, and minimum amounts before trying to send.

5

For cross-currency, use pathfinding:

Let the network find optimal paths, but set appropriate SendMax limits and execute quickly. ---