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 — 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 — 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 — 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 — 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 — never re-pay

Section titled “Recovering a broadcast proof — never re-pay”

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 — 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 — 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.

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, 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.