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: { privateKey: 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.

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.

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 — balanceOf, recipientReady, estimateCost(), and the composed planPayment() — deliberately never throw. 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 only throw on that path is InvalidEnvelopeError on an unparseable challenge — and 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.