planPayment()
Introduction
Section titled “Introduction”planPayment(url) answers the question an autonomous agent must ask before spending: can I
actually pay this, and which rail is best? It reads on-chain (balances, gas, recipient
prerequisites) but never pays — it returns a structured PaymentPlan you can branch on.
It completes the read-only trio: quote() learns the price,
estimateCost() adds the gas, and planPayment() decides
whether the whole thing is settleable.
It returns null when the URL isn’t payment-gated (no 402), so null-guard the result before
reading it.
Basic use
Section titled “Basic use”import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'base', wallet: { privateKey: process.env.AGENT_KEY! },})
const url = 'https://api.example.com/report'const plan = await client.planPayment(url)
if (!plan) { // not payment-gated — fetch it for free await client.fetch(url)} else if (plan.payable) { await client.fetch(url) // safe — we checked} else { console.log(plan.fundingHint) // one-line, human-readable: what's missing}The PaymentPlan
Section titled “The PaymentPlan”interface PaymentPlan { url: string network: Caip2 // the network this client is bound to, e.g. 'eip155:8453' status: 'ready' | 'blocked' | 'unknown' // top-level verdict for instant branching payable: boolean // is at least one rail settleable right now? (best !== null) best: PayOption | null // the recommended rail (cheapest settleable), or null options: PayOption[] // every offered+supported rail, ranked: payable → unknown → blocked fundingHint: string | null // when NOT payable: one human sentence on what to top up (null when payable) session?: { // present ONLY when the policy configures a time envelope expiresAt: number | null // deadline as epoch ms, or null when no time limit secondsRemaining: number | null // best-effort, clamped ≥ 0, or null when no time limit }}status is the quick verdict: 'ready' (a rail is payable now), 'blocked' (every rail has a
hard blocker), or 'unknown' (a read failed, so payability can’t be confirmed — retry).
Each PayOption analyses one offered rail against your wallet:
interface PayOption { accept: X402AnyAccept // the rail this analyses (one entry from the 402's accepts[]) quote: PipRailQuote // the priced requirement — TRUE decimals/symbol + the policy verdict cost: CostEstimate // estimated native-coin gas to send it (cost.basis surfaced) state: 'payable' | 'blocked' | 'unknown' // the verdict for THIS rail blockers: PayBlocker[] // hard reasons it can't settle (empty when payable) warnings: PayWarning[] // soft flags worth surfacing (may be present even when payable) balance: { token: string | null; native: string | null } // live holdings, human units (null = unread, NOT zero) need: { token: string; native: string } // the payment amount + the estimated gas shortfall?: { token?: string; native?: string } // how far short, if blocked on funds recipient: { ready: boolean | 'n/a' | 'unknown'; reason?: RecipientReason; fix?: string }}Blockers — the hard reasons a rail can’t settle
Section titled “Blockers — the hard reasons a rail can’t settle”| Blocker | Meaning |
|---|---|
INSUFFICIENT_TOKEN | Not enough of the payment token. |
INSUFFICIENT_GAS | Not enough native coin to cover the network fee. |
RECIPIENT_NOT_READY | payTo can’t receive yet (no trustline / not registered / not opted-in / inactive). |
OUTSIDE_POLICY | The payment breaches your spend policy. |
OUTSIDE_WINDOW | The rolling time window or session deadline has passed. |
Warnings — soft flags
Section titled “Warnings — soft flags”SYMBOL_MISMATCH, BALANCE_UNREADABLE, RECIPIENT_READINESS_UNKNOWN, GAS_HEURISTIC,
THIN_GAS_MARGIN. A warning doesn’t block a payment, but an agent should surface it (for
example, a symbol mismatch means the token’s on-chain symbol differs from the challenge).
When it throws (and when it doesn’t)
Section titled “When it throws (and when it doesn’t)”planPayment() is fundability-as-data: an unaffordable, recipient-not-ready, or out-of-policy
rail is reported as a blocker on a PayOption, never thrown. A transient RPC failure
surfaces as state: 'unknown' plus a warning, never a false “unaffordable”.
The one exception is a malformed challenge: a 402 whose body isn’t a parseable x402 envelope
throws InvalidEnvelopeError.
import { InvalidEnvelopeError } from '@piprail/sdk'
try { const plan = await client.planPayment(url) // …branch on plan…} catch (err) { if (err instanceof InvalidEnvelopeError) { console.error('Server returned a 402 with a malformed payment challenge:', err.message) } else { throw err }}canAfford() — the boolean shortcut
Section titled “canAfford() — the boolean shortcut”When you only need a yes/no, canAfford(url) runs the same plan and returns plan.payable
(or true when the URL isn’t gated — a free resource is trivially affordable):
if (await client.canAfford(url)) { await client.fetch(url)}// → true | falseAcross chains — planAcross
Section titled “Across chains — planAcross”To decide which of several chains to pay from, plan across single-chain clients. It returns a
merged PaymentPlan | null — null only when the URL isn’t gated for any client:
import { planAcross } from '@piprail/sdk'
const plan = await planAcross([baseClient, solanaClient, polygonClient], url)// merged, payable-first — `best` points at the chain to useif (plan?.best) { console.log(`Pay on ${plan.best.accept.network}`)}Make it legible to an LLM
Section titled “Make it legible to an LLM”summarizePlan(plan) renders the plan as one English line for a model to read or relay:
import { summarizePlan } from '@piprail/sdk'
console.log(summarizePlan(plan))// → "Payable: 0.10 USDC on eip155:8453 (gas ~0.00002 ETH). 1 other rail(s) not settleable."Gas is shown in the chain’s native coin (ETH/SOL/TRX/…), never in fiat — PipRail has no price
oracle. A null plan renders "No payment required — the URL is not payment-gated."; an
unpayable plan renders "NOT payable: <fundingHint>".
See the agent toolkit for how the MCP server wires this into a tool.