x402 on the XRP Ledger
x402 turns HTTP's 402 Payment Required into a protocol: the server names a price, the client signs a payment and retries, the server settles it and serves the resource. On the XRP Ledger, the client signs a Payment it does not submit.
This page summarises x402 v2 and its XRPL exact scheme as pinned at x402-foundation/x402@6b6ee91fee02. That repository is canonical; coinbase/x402 is an older fork without the XRPL files. Where this page and the spec disagree, the spec wins.
The flow
The three headers
| Header | Direction | Carries (standard base64 of UTF-8 JSON) |
|---|---|---|
PAYMENT-REQUIRED | Server → client, on 402 | { x402Version: 2, error?, resource, accepts: [...], extensions? } |
PAYMENT-SIGNATURE | Client → server, on the retry | { x402Version: 2, resource?, accepted: <the chosen entry, unchanged>, payload: { signedTxBlob, invoiceId? }, extensions? } |
PAYMENT-RESPONSE | Server → client, with the result | { success, transaction, network, payer?, errorReason?, amount?, extensions? } |
A failed payment is a 402 with PAYMENT-RESPONSE success: false; a malformed one is a 400. errorReason is an open string, not a closed list. XRPL facilitators do not send settlement_pending; they send transaction_failed: <code> with the hash.
x402 v1 (X-PAYMENT) has no XRPL encoding. OpenWallet refuses a v1 challenge with x402_v1_unsupported.
A requirement (accepts[] entry)
{
"scheme": "exact",
"network": "xrpl:0",
"asset": "524C555344000000000000000000000000000000",
"amount": "0.05",
"payTo": "rMerchant…",
"maxTimeoutSeconds": 60,
"extra": {
"areFeesSponsored": false,
"issuer": "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De",
"invoiceId": "inv_…",
"destinationTag": 12345
}
}
| Field | Rule |
|---|---|
network | xrpl:{NetworkID}: xrpl:0 Mainnet, xrpl:1 Testnet, xrpl:2 Devnet (CAIP-2). |
asset | "XRP", a 3-character currency code, or 40 hex characters. |
amount | XRP: an integer string of drops (1 XRP = 1,000,000). Tokens: the exact value string, such as "0.05", not atomic units. There is no decimals. |
extra.areFeesSponsored | Required and false: the payer pays the XRPL fee. |
extra.issuer | Required for tokens. |
extra.invoiceId | Optional. Becomes the transaction's InvoiceID (below). |
extra.destinationTag | Optional uint32. Becomes DestinationTag. |
extra.assetTransferMethod | sequence (default) or ticketSequence. |
extra.paymentFlow | authorization (default: settle after the handler runs) or upfront (settle first). A client must not pay a flow it does not recognise; OpenWallet skips escrow. |
The signed Payment
TransactionTypePayment,Destination=payTo,DestinationTagwhen given.- XRP:
Amountis the drops string. NoSendMax. - Tokens:
Amountis{currency, issuer, value}, equal by exact decimal comparison, and aSendMaxin the same currency and issuer of at least the amount (RLUSD has no transfer fee, so they are equal). - Never
Paths,DeliverMin,MemosorDelegate, and never the partial-payment flag. InvoiceID= upper-case hex SHA-256 of the UTF-8invoiceId. Memos must not be used to bind a payment.LastLedgerSequenceabove the current validated ledger and at most validated +ceil(maxTimeoutSeconds / 5) + 2. OpenWallet caps it lower, at 20 ledgers (about 80 s).NetworkIDis omitted for Mainnet, Testnet and Devnet (ids up to 1024).- One signature, by the master key or the account's
RegularKey. The fee is the payer's; the reference facilitator caps it at 10,000 drops.
Sequence or tickets
With sequence, the payment uses the account's next sequence number, so the payer can have one payment pending at a time, and a payer who moves the sequence can make a payment fail after the merchant's handler ran. ticketSequence uses a ticket reserved in advance (sequence 0), which allows several pending payments and long handlers. OpenWallet uses tickets only when the grant allows them.
Separate accounts per network
Standard XRPL networks omit NetworkID from transactions, so a blob signed for Testnet is valid on Mainnet if the same account and sequence exist there. The spec says wallets should use separate accounts per network. OpenWallet derives Mainnet and test accounts on different paths, so they never share a key.
Settlement and facilitators
A facilitator exposes /verify, /settle and /supported. It re-verifies, refuses a hash it has seen, submits only to the named network, waits for validated, and counts only tesSUCCESS as success. The spec requires it to deduplicate on the transaction hash, atomically, until the payment's LastLedgerSequence passes.
| Facilitator | Networks | Notes |
|---|---|---|
| x402.org | xrpl:1 (Testnet only) | Follows the spec. Ignores an extra payload.invoiceId. |
| t54 | xrpl:0 and xrpl:1 | Needs payload.invoiceId and a SourceTag (asked for with extra.sourceTag). Omits areFeesSponsored in its 402s. Accepts Memos. Only its Testnet behaviour has been probed by us. |
In one Testnet test on 30 September 2026, both public facilitators accepted a duplicate settlement of the same blob. That is a single sample, but it is why OpenWallet's merchant library verifies and submits by itself by default, and why it always reads the ledger before releasing a resource: a facilitator is at most a relay, and the ledger decides.
x402 over MCP
- Payment required: the tool result has
isError: true,structuredContent= thePaymentRequiredobject andcontent[0].text= its JSON.resource.urlis conventionallymcp://tool/<name>. - Payment: the client retries the same
tools/callwithparams._meta["x402/payment"]= the payload object (not base64). - Result:
_meta["x402/payment-response"]. If settlement fails after the tool ran, the server returns only the payment error, never the tool's output.
Most MCP clients do not let a model set _meta, which is why openwallet_call_paid_mcp_tool makes the remote call itself. As a client, OpenWallet also parses shapes seen in the wild (_meta["x402/error"], JSON-RPC errors with code 402, or -32042 only when data carries x402Version and accepts).
Why upto is not here
An upto scheme (pay up to a maximum, settle the actual amount) is not standardised on the XRP Ledger. A draft based on payment channels is open and unmerged, and one facilitator's Testnet upto uses Checks and is its own design. OpenWallet does not pay upto challenges and refuses them with unsupported_scheme. The same goes for batch settlement.