Skip to content

PipRailClient

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.

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.

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 paid

get 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 + symbol
const cost = await client.estimateCost(url) // { quote, cost } — payment + estimated gas
const 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. Returns payable, best, per-rail blockers, and a one-line fundingHint.

canAfford(url) is the boolean shortcut over planPaymenttrue 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)
}

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?

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.

The client keeps an in-memory ledger, the basis for lifetime spend caps:

client.spent() // { count, byAsset, records } — everything settled this session
client.budget() // { session, byAsset } — remaining per-asset leash + the time envelope
client.remaining() // SpendRemaining[] — remaining budget per (network, asset)

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.

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 null

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.

OptionPurpose
chainThe chain this client pays on.
walletThe per-family wallet (see above).
rpcUrlYour RPC (fold any API key in here).
policyThe spend policy — caps, allowlists, time window.
onBeforePayApproval hook — receives the PipRailQuote; returning false or throwing refuses the payment (PaymentDeclinedError, reasonCode: 'APPROVAL'), before any send.
onEventLifecycle observability callback.
autoRouteDefault for fetch’s cheapest-rail routing (default off).
schemesWhich schemes to settle — ['onchain-proof'] (default) or add 'exact'.
maxPaymentRetries / retryTimeoutMsRetry/timeout tuning (defaults: 3 attempts, 30_000 ms).

Next: planPayment() — the readiness check every agent should run first.