PipRailClient
Introduction
Section titled “Introduction”PipRailClient is the buyer side of PipRail. At its simplest it’s a fetch that pays when it
hits a 402; underneath, it’s a complete agent payment toolkit — learn a price without paying,
check you can afford it, enforce a spend policy, and read back what you spent.
Construct a client
Section titled “Construct a client”One client is bound to one chain and one wallet:
import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'base', wallet: { privateKey: process.env.AGENT_KEY },})The wallet shape depends on the family — { privateKey } for EVM/Tron/Sui/Aptos,
{ secretKey } for Solana, { mnemonic } for TON/Algorand, { secret } for Stellar,
{ seed } for XRPL, { accountId, privateKey } for NEAR. You can also pass a viem
{ walletClient } to use an injected browser wallet. See Wallets by
family.
Pay automatically — fetch / get / post
Section titled “Pay automatically — fetch / get / post”fetch is identical to the global fetch, except a 402 is handled transparently: it reads
the challenge, pays on-chain, retries with the proof, and returns the unlocked response. It
takes the same second argument as the global fetch — a RequestInit (plus the optional
autoRoute / schemes flags below):
const res = await client.fetch('https://api.example.com/report')const data = await res.json()// → the unlocked 200 response — same shape the server returns once paidget and post are thin conveniences over fetch (post JSON-serialises a plain object); both
take the same optional RequestInit second argument as fetch:
const res = await client.post('https://api.example.com/jobs', { prompt: 'summarise Q3' })Look before you pay — the read-only trio
Section titled “Look before you pay — the read-only trio”These move no funds — they’re how an agent decides. planPayment and estimateCost never throw
for a read problem (a flaky RPC surfaces as a warning, not a false “broke”); quote raises
InvalidEnvelopeError only if the challenge itself is unparseable. Each returns null when the
URL isn’t payment-gated (no 402), so null-guard the result:
const url = 'https://api.example.com/report'
const quote = await client.quote(url) // price, with the token's TRUE decimals + symbolconst cost = await client.estimateCost(url) // { quote, cost } — payment + estimated gasconst plan = await client.planPayment(url) // can I actually settle? per-rail analysis
if (plan?.payable) { await client.fetch(url) // safe — we checked} else { console.log(plan?.fundingHint) // one-line, human-readable: what's missing}quote()— learn the price without paying it.estimateCost()— add a best-effort native-coin gas estimate so you budget payment + gas.planPayment()— the full readiness check across every rail the 402 offers: balance, gas, recipient-readiness, policy. Returnspayable,best, per-railblockers, and a one-linefundingHint.
canAfford(url) is the boolean shortcut over planPayment — true when at least one rail is
settleable (or when the URL isn’t gated at all):
if (await client.canAfford(url)) { await client.fetch(url)}Auto-routing and multiple chains
Section titled “Auto-routing and multiple chains”fetch(url, { autoRoute: true }) (opt-in, default off) pays the cheapest settleable rail a
402 offers on your chain. If nothing is settleable it throws PaymentDeclinedError carrying the
funding hint — before any send. To choose across chains, give planAcross several single-chain
clients:
import { planAcross } from '@piprail/sdk'
const plan = await planAcross([baseClient, solanaClient, polygonClient], url)// → merged, payable-first: which chain should I pay from?When a payment can’t go through
Section titled “When a payment can’t go through”Every read-only method returns a value you can branch on, but fetch (and get/post) pays,
so it throws a typed PipRailError on failure. Branch on the stable
.code — and on the two broadcast-but-unconfirmed codes, recover via the proof on .ref, never
re-pay (a fresh payment would double-spend):
import { PipRailError, PaymentTimeoutError, MaxRetriesExceededError,} from '@piprail/sdk'
try { const res = await client.fetch('https://api.example.com/report') return await res.json()} catch (err) { if (err instanceof PaymentTimeoutError || err instanceof MaxRetriesExceededError) { // Broadcast succeeded; the server just hasn't accepted the proof yet. // Re-verify or re-submit err.ref — DON'T re-pay. console.warn('payment in flight — recover with ref', err.ref) return } if (err instanceof PipRailError) { switch (err.code) { case 'PAYMENT_DECLINED': // your policy or onBeforePay refused it (no funds moved) case 'INSUFFICIENT_FUNDS': // top up the payer (token and/or native gas) case 'RECIPIENT_NOT_READY': // the fix is on the recipient, not your balance console.error(err.code, err.message) return default: throw err } } throw err}.ref lives on exactly two error classes — PaymentTimeoutError and MaxRetriesExceededError —
so narrow to those before reading it. See the error model for the full
list of codes.
What did I spend?
Section titled “What did I spend?”The client keeps an in-memory ledger, the basis for lifetime spend caps:
client.spent() // { count, byAsset, records } — everything settled this sessionclient.budget() // { session, byAsset } — remaining per-asset leash + the time envelopeclient.remaining() // SpendRemaining[] — remaining budget per (network, asset)Find and list resources — discovery
Section titled “Find and list resources — discovery”discover() reads the free open x402 indexes and returns resources payable on this client’s
chain; register() lists a resource you run so other agents can find it. Both move no funds and
never throw for a read problem:
const resources = await client.discover({ query: 'weather' })// → DiscoveredResource[] — feed one straight into quote() → planPayment() → fetch()claimDomain(urlOrDomain, opts?) and verifyDomain(urlOrDomain) prove you own a domain so your
402 Index listings go live — see Domain verification.
discoverySigner()
Section titled “discoverySigner()”client.discoverySigner() returns the wallet’s discovery signer — a { address, signMessage }
used only to sign in to indexes that require a wallet signature (x402scan’s SIWX), never to move
funds. It resolves to null on a family that has no discovery signer (today it’s EVM-only). You
rarely call it directly — register(url, { targets: ['x402scan'] }) uses it under the hood — but
it’s there if you sign an index challenge by hand.
const signer = await client.discoverySigner()// → { address, signMessage } on EVM, else nullObservability
Section titled “Observability”Pass onEvent to watch the lifecycle — payment-required, payment-broadcast,
payment-confirmed, payment-settled, payment-failed (plus payment-unconfirmed when the
broadcast lands but local confirmation times out) — for logging or a UI.
Constructor options
Section titled “Constructor options”| Option | Purpose |
|---|---|
chain | The chain this client pays on. |
wallet | The per-family wallet (see above). |
rpcUrl | Your RPC (fold any API key in here). |
policy | The spend policy — caps, allowlists, time window. |
onBeforePay | Approval hook — receives the PipRailQuote; returning false or throwing refuses the payment (PaymentDeclinedError, reasonCode: 'APPROVAL'), before any send. |
onEvent | Lifecycle observability callback. |
autoRoute | Default for fetch’s cheapest-rail routing (default off). |
schemes | Which schemes to settle — ['onchain-proof'] (default) or add 'exact'. |
maxPaymentRetries / retryTimeoutMs | Retry/timeout tuning (defaults: 3 attempts, 30_000 ms). |
Next: planPayment() — the readiness check every agent should
run first.