Skip to content

The error model

PipRail reports every failure through exactly two chain-agnostic channels, and only two. An EVM, Solana, TON, or Stellar failure looks identical to you. You always get a typed reason, never a raw viem / @solana / @ton / @stellar error for a condition the SDK recognises.

ChannelShapeFor
1THROWNa typed PipRailError subclass with a stable .codeconfig / flow / wallet / registry / affordability, the things you act on
2RETURNEDa VerifyErrorCode on { ok: false, error, detail }the outcome of verifying an on-chain proof, server side

The rule of thumb: config / flow / wallet / registry / affordability → throw; proof-verification outcome → return. This page is the map; the dedicated pages drill into each channel.

The imperative path (paying, binding a wallet, resolving a token, talking to the registry) throws a typed error. Every one extends the abstract PipRailError base class, so you filter SDK-originated failures from arbitrary ones in one check:

import { PipRailClient, PipRailError } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { key: process.env.AGENT_KEY! },
})
try {
const res = await client.fetch('https://api.example.com/report')
const data = await res.json()
} catch (err) {
if (err instanceof PipRailError) console.log(err.code, err.message)
else throw err
}

Or branch directly on .code, a stable SCREAMING_SNAKE string that never changes:

try {
await client.fetch('https://api.example.com/report')
} catch (err) {
if (err instanceof PipRailError && err.code === 'PAYMENT_DECLINED') {
// over policy / refused before any send, so nothing moved
} else throw err
}

Every PipRailError supports the standard { cause } option, so the untouched chain error stays attached on .cause for deeper debugging while the message reads in plain language. All concrete classes are exported from @piprail/sdk. The full set of classes and their .code values is the error hierarchy.

A driver’s verify() reports why a proof was rejected without throwing. It returns a VerifyResult. This is the one place verification fails closed instead of throwing.

type VerifyResult =
| { ok: true; receipt: X402Receipt }
| { ok: false; error: VerifyErrorCode; detail: string }

error is a closed snake_case union the compiler enforces (tx_not_found, amount_too_low, tx_reverted, and so on) so a driver can’t invent a code, and the same condition uses the same code everywhere. The gate turns a rejection into a conformant 402 re-challenge (carrying accepts[] so a standard client can retry, the reason in error, the machine code in extensions.piprail.{code,detail}). See the verify error code page for the full union and which driver emits each.

The gate exposes both verdicts as fully-isolated observability hooks, the mirror pair onPaid and onFailed: onPaid fires with the enriched receipt on a settled proof, onFailed fires with a FailedPayment on a rejected one, carrying the same VerifyErrorCode the buyer’s client is told (so both sides see one reason). A throw inside either is caught and routed to its …Error seam, so neither can break the request.

Affordability always converges on one error

Section titled “Affordability always converges on one error”

“Wallet can’t pay” always surfaces as a single thrown InsufficientFundsError (.code === 'INSUFFICIENT_FUNDS'), no matter which chain detected it. The detection differs per family, the result never does:

try {
await client.fetch('https://api.example.com/report')
} catch (err) {
if (err instanceof PipRailError && err.code === 'INSUFFICIENT_FUNDS') {
// fund the payer with more token, native gas, or reserve
} else throw err
}

Message-regex drivers (Solana, TON) match the chain library’s “can’t afford it” message via the exported toInsufficientFundsError(err) helper; structured-error drivers (EVM, Stellar) detect it from typed data (viem’s BaseError chain, Horizon result_codes) and also fall through to the same helper as a backstop, so the two paths can’t drift in vocabulary.

Recovering a broadcast proof, and never re-paying

Section titled “Recovering a broadcast proof, and never re-paying”

Once a payment broadcasts, funds may have moved. If the broadcast succeeds but the server then times out or keeps returning 402, the client throws PaymentTimeoutError or MaxRetriesExceededError, and only these two carry the already-broadcast proof on .ref. Narrow on the concrete class before reading it (the PipRailError base has no .ref):

import { PaymentTimeoutError, MaxRetriesExceededError } from '@piprail/sdk'
try {
await client.fetch('https://api.example.com/report')
} catch (err) {
if (
(err instanceof PaymentTimeoutError || err instanceof MaxRetriesExceededError) &&
err.ref
) {
// re-verify or RE-SUBMIT err.ref. Never start a fresh payment
} else throw err
}

The proof stays redeemable until the server’s maxTimeoutSeconds recency window elapses (default 600s). A fresh payment would double-spend, so the recovery rule for these two codes is: read .ref, re-submit it, never re-pay. A ConfirmationTimeoutError from your own confirm() poll behaves the same way (the tx may still land) but it carries no .ref; re-check the proof ref you already hold from send(), don’t expect one on the error.

For the full picture of what happens to a payment when a request doesn’t finish, on both the client and the gate side, see Replay protection & recovery.

A PaymentDeclinedError is thrown before any send: the quote exceeded your spend policy, or an onBeforePay hook said no. Its .code stays 'PAYMENT_DECLINED', but it carries an optional typed .reasonCode so an agent can branch on why without parsing prose:

import { PaymentDeclinedError } from '@piprail/sdk'
try {
await client.fetch('https://api.example.com/report')
} catch (err) {
if (err instanceof PaymentDeclinedError) {
// err.reasonCode: 'POLICY' | 'BUDGET' | 'OUTSIDE_WINDOW' | 'SESSION_EXPIRED' | 'APPROVAL'
} else throw err
}

'SESSION_EXPIRED' and 'APPROVAL' are terminal, so every payment this process makes is now refused, so don’t auto-retry; restart or extend the time envelope first. This adds no new .code and no new class; it’s a hint layered on the always-reliable two-channel model.

The two channels are designed so a model never sees a raw exception. The agent toolkit funnels everything: its piprail_pay_request tool catches every PipRailError and returns a structured { ok: false, code, reason, explain, ref?, reasonCode?, declined? } instead of letting it crash the loop, so a broadcast-but-unconfirmed PAYMENT_TIMEOUT reaches the model with its .ref and the never-re-pay rule attached. Only a genuine non-PipRailError bug rethrows.

The read-only completion of the trio, planPayment() (and its inputs estimateCost(), plus the drivers’ balanceOf / recipientReady SPI it composes), deliberately never throw. The driver-SPI balanceOf / recipientReady are surfaced only via planPayment().options[].balance / .recipient, never as client methods. Like verify(), they return their outcome: a transient read becomes a rail in state: 'unknown' with a warning, an unsettleable rail carries typed blockers, and a missing field comes back null (never a false 0). The throws on that path are InvalidEnvelopeError (an unparseable challenge) and WalletRequiredError (planPayment() / canAfford() called on a read-only client built with no wallet, since they read YOUR balance, gas, and recipient-readiness), plus fetch(url, { autoRoute: true }), the one place a plan turns into a thrown PaymentDeclinedError when nothing is settleable.

Swapping follows the same split. quoteSwap() is a read and never throws for a read problem: no route, no liquidity, a dead node or a hostile response all return null, and on a read-only client it returns null rather than demanding a wallet. The one thing it throws is a malformed slippageBps, as a plain RangeError before any read, because that is a bug in your code and hiding it behind null would read as “no route”. swap() is the write: WalletRequiredError on a read-only client, UnsupportedNetworkError on a chain with no route (the message names every venue that does exist) or for a quote from another network, and InsufficientFundsError when the wallet cannot cover it or the chain rejects the swap, which includes the market moving past the slippage cap. Nothing is swapped in that case; chains that charge for a reverted transaction (EVM, Aptos, Tron) still take the gas.

Discovery is read-style too: client.discover() reports [] for a dead index rather than throwing, and client.register() returns one { ok, detail } outcome per target, surfaced and never swallowed.

This whole model is the SDK’s single standard; it is specified in sdk/ERRORS.md, and every module and chain driver conforms to it by construction.