The error model
Introduction
Section titled “Introduction”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.
| Channel | Shape | For | |
|---|---|---|---|
| 1 | THROWN | a typed PipRailError subclass with a stable .code | config / flow / wallet / registry / affordability — things you act on |
| 2 | RETURNED | a 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.
Channel 1 — a thrown PipRailError
Section titled “Channel 1 — a thrown PipRailError”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.
Channel 2 — a returned VerifyErrorCode
Section titled “Channel 2 — a returned VerifyErrorCode”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.
Branching on a decline reason
Section titled “Branching on a decline reason”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.
What an AI agent receives
Section titled “What an AI agent receives”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.
Where read-only methods sit
Section titled “Where read-only methods sit”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.