Skip to content

Why did my payment fail?

When a payment doesn’t go through, PipRail tells you why in a typed, actionable way — never an opaque chain-library blob. Every failure surfaces through one of two channels: a thrown PipRailError with a stable .code (catch it, branch on it), or a returned VerifyErrorCode on the server side. This page is the triage map for the thrown ones, ordered by what you’ll actually hit.

The single most important rule lives at the bottom: a broadcast payment is never thrown away, so you recover with the proof — you never re-pay.

Every error the SDK throws extends PipRailError, so you separate SDK-recognised failures from arbitrary bugs in one check, then branch on the stable .code:

import { PipRailClient, PipRailError } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
})
const url = 'https://api.example.com/report'
try {
const res = await client.fetch(url)
// → a normal Response — payment (if any) already settled
console.log(res.status)
} catch (err) {
if (err instanceof PipRailError) console.log(err.code, err.message)
else throw err // a genuine bug, not a payment outcome
}

The cleaner path is to not throw at all: planPayment(url) and canAfford(url) read your wallet and the recipient before spending and return a structured verdict, so most of the errors below become a pre-flight decision instead of a runtime crash.

Payer vs recipient — the two faces of “can’t pay”

Section titled “Payer vs recipient — the two faces of “can’t pay””

The most common confusion is treating every shortfall as “I’m broke.” PipRail keeps the two cases deliberately distinct, because the fix is the opposite — fund yourself, or set up the recipient.

CodeClassThe fix
INSUFFICIENT_FUNDSInsufficientFundsErrorFund the payer — more of the payment token, or native coin for gas/reserve.
RECIPIENT_NOT_READYRecipientNotReadyErrorSet up the recipient (payTo) — it isn’t provisioned to receive on this chain yet.

Affordability always converges on one error on every chain — InsufficientFundsError, .code === 'INSUFFICIENT_FUNDS' — whether the driver detected it from a structured chain error or by matching the message:

import { InsufficientFundsError, RecipientNotReadyError } from '@piprail/sdk'
try {
await client.fetch(url)
} catch (err) {
if (err instanceof InsufficientFundsError) {
// top up; err.cause has the raw chain error for debugging
console.log('Fund the payer:', err.message)
} else if (err instanceof RecipientNotReadyError) {
// the payTo isn't set up to receive — see below
console.log('Set up the recipient:', err.message)
} else {
throw err
}
}

RecipientNotReadyError is a chain state rule, not your balance. It fires on the few families with a receive prerequisite, and the message states the requirement plus the raw chain code:

ChainThe recipient needs…
XRPLactivation — an account must hold ≥1 XRP (base reserve) to exist; or a trustline / DestinationTag for the IOU.
Stellarthe account to exist (≥1 XLM reserve) and hold a trustline for the asset.
NEARstorage_deposit registration on the NEP-141 token (~0.00125 NEAR).
Algoranda one-time ASA opt-in for the issued token (e.g. USDC) — a 0-amount self-transfer. Native ALGO needs nothing.

Chains with no receive prerequisite (EVM, Solana, Sui, Aptos, Tron, TON, and native NEAR/ALGO) never throw it.

Refused before any send — PAYMENT_DECLINED

Section titled “Refused before any send — PAYMENT_DECLINED”

PaymentDeclinedError is thrown before any on-chain send: the quote exceeded your spend policy (a per-payment / lifetime / rolling cap, or a chain/token/host outside the allowlist), the session’s time envelope elapsed, or an onBeforePay approval hook said no. Nothing moved. A typed .reasonCode lets you branch on the cause without parsing the message:

import { PaymentDeclinedError } from '@piprail/sdk'
try {
await client.fetch(url)
} catch (err) {
if (err instanceof PaymentDeclinedError) {
if (err.reasonCode === 'SESSION_EXPIRED' || err.reasonCode === 'APPROVAL') {
return // TERMINAL — do not retry
}
// 'POLICY' | 'BUDGET' | 'OUTSIDE_WINDOW' — adjust the cap or wait for the window to slide
console.log('Declined:', err.reasonCode, err.message)
} else {
throw err
}
}

SESSION_EXPIRED and APPROVAL are terminal — every further payment this process is refused, so restart or extend the session rather than retrying.

Nothing the wallet can pay — NO_COMPATIBLE_ACCEPT and UNSUPPORTED_SCHEME

Section titled “Nothing the wallet can pay — NO_COMPATIBLE_ACCEPT and UNSUPPORTED_SCHEME”

These two say “the 402 offered rails, but not ones this wallet can settle.” Keep them apart:

CodeMeaning
NO_COMPATIBLE_ACCEPTThe challenge offered no accepts[] entry for the client’s network and enabled schemes. The message names the enabled schemes.
UNSUPPORTED_SCHEMEAsked to pay a scheme the bound family/asset/signer can’t settle, with no fallback — e.g. an exact rail on a non-EVM family, a non-EIP-3009 token (USDT, native, plain ERC-20), or a contract / EIP-1271 / EIP-7702 signer.

The usual fix for UNSUPPORTED_SCHEME is to keep an onchain-proof rail in the offer, or pay with a supported chain/token. Note WRONG_FAMILY is different again — that’s a wallet, payTo, or token given in another family’s shape (an 0x… address on Solana, a { mint } token on Stellar).

Setup mistakes — wrong family, unknown token, missing driver

Section titled “Setup mistakes — wrong family, unknown token, missing driver”

These are configuration, not funds:

CodeClassThrown when
WRONG_FAMILYWrongFamilyErrorA wallet / payTo / token in another family’s shape.
WRONG_CHAINWrongChainErrorA bring-your-own walletClient is on a different chain than configured.
UNKNOWN_TOKENUnknownTokenErrorA built-in symbol the chain doesn’t ship (e.g. token: 'DOGE') — use a symbol the chain ships, 'native', or pass the token by full descriptor.
MISSING_DRIVERMissingDriverErrorA non-EVM family’s optional peer deps aren’t installed; the message names the exact npm install.
UNSUPPORTED_NETWORKUnsupportedNetworkErrorNo driver recognised the chain value.
INVALID_ENVELOPEInvalidEnvelopeErrorA 402 carried no parseable x402 challenge.

MISSING_DRIVER (deps not installed) and UNSUPPORTED_NETWORK (chain not supported) are a deliberate split — don’t conflate them. See Chains and tokens and Wallets by family.

The broadcast-but-stuck case — never re-pay

Section titled “The broadcast-but-stuck case — never re-pay”

This is the one that matters most for an autonomous agent. Once a transfer broadcasts, the funds may have moved — so the client never throws the proof away. If the broadcast succeeds but the client’s own confirm() times out (a throttled RPC that lands the tx but 429s the status poll), it emits a payment-unconfirmed event, submits the proof to the server anyway with more patient retries, and never re-broadcasts. If it still can’t confirm, it throws one of these — the first two carry .ref, the already-broadcast proof:

CodeClassMeans
PAYMENT_TIMEOUTPaymentTimeoutErrorBroadcast confirmed on-chain, but the server didn’t return 200 within the timeout. Carries .ref.
MAX_RETRIES_EXCEEDEDMaxRetriesExceededErrorBroadcast, retried, still 402. The message embeds the last server error — detail. Carries .ref.
CONFIRMATION_TIMEOUTConfirmationTimeoutErrorBroadcast OK but didn’t confirm in the driver’s window. Re-check the proof ref you already have (no .ref on this class).

.ref lives on exactly these two classes, so narrow on them before reading it (the base PipRailError and ConfirmationTimeoutError have no .ref):

import { PaymentTimeoutError, MaxRetriesExceededError } from '@piprail/sdk'
try {
await client.fetch(url)
} catch (err) {
if (err instanceof PaymentTimeoutError || err instanceof MaxRetriesExceededError) {
const proof = err.ref // recover with this — do NOT pay again
// re-submit `proof` to the resource, or re-verify it on-chain
console.log('Recover the proof, never re-pay:', proof)
} else {
throw err
}
}

For an exact rail the .ref is the EIP-3009 authorization nonce (a 0x… 32-byte value, not a tx hash): re-present the same signed authorization, and check the token’s authorizationState(from, nonce) before assuming it didn’t settle.

Why a flaky RPC is safe — no false unlock, no double-pay

Section titled “Why a flaky RPC is safe — no false unlock, no double-pay”

The same broadcast-is-sacred principle makes a throttled or lagging RPC harmless in both directions:

  • Verify fails closed (server). If the gate’s verify() RPC read fails, it returns tx_not_found and replies 402 (locked) — never paid. An RPC outage can’t trick a merchant into unlocking without a real, confirmed payment. The gate also releases the replay claim on a failed read, so the payer can re-submit the same proof once the RPC recovers — the proof is not burned. See Replay protection.
  • Confirm fails open, toward the proof (client). A confirm timeout doesn’t discard the broadcast; it defers to the server’s on-chain verify (the authority) and never re-broadcasts.

The repeated-lag fix is a dedicated rpcUrl per chain, not retrying the payment.

Settlement failed on an exact rail — a 5xx, not your fault

Section titled “Settlement failed on an exact rail — a 5xx, not your fault”

On a standard exact rail, the merchant (or a facilitator) broadcasts. If your authorization was valid (signature recovered, simulation passed) but the merchant’s relayer or facilitator couldn’t broadcast it, the gate throws SettlementError (SETTLEMENT_FAILED) and the adapter returns 5xx, never 402. Your signed EIP-3009 authorization stays valid and its nonce unused — re-present it once the merchant fixes their relayer. Re-paying would be wrong here too.

When you’d rather not handle any of this

Section titled “When you’d rather not handle any of this”

Agents that should never let a payment failure crash the loop should drive PipRail through the agent toolkit: the piprail_pay_request tool catches every PipRailError and returns a structured result instead of an exception — { ok: false, code, reason, explain, ref?, reasonCode?, declined? } — so a broadcast-but-unconfirmed timeout reaches the model with its .ref and the never-re-pay rule already explained.