Skip to content

Challenge triage

When a 402 can’t be paid, why matters: “you’re on the wrong chain” and “this rail uses a scheme you haven’t turned on” need different fixes, yet a single NO_COMPATIBLE_ACCEPT hides both. classifyChallenge triages an x402 challenge into one of four plain verdicts so an agent (or a human reading its logs) knows which lever to pull.

It is a pure, never-throwing read — no chain libraries, no I/O. You feed it a challenge you already parsed plus your client’s bound network and enabled schemes, and it buckets the offered rails.

Parse the 402 with parseChallenge, then classify it against the chain and schemes you can settle. parseChallenge returns X402Challenge | null (null when the response wasn’t a readable x402 challenge), so null-guard it before classifying:

import { parseChallenge, classifyChallenge } from '@piprail/sdk'
const res = await fetch('https://api.example.com/report') // a 402
const challenge = await parseChallenge(res)
if (!challenge) return // not an x402 challenge — nothing to triage
const triage = classifyChallenge(challenge, {
network: 'eip155:8453', // your bound chain, CAIP-2
schemes: ['onchain-proof'], // the schemes you've enabled
})
console.log(triage.verdict) // → 'WRONG_CHAIN'
// → { onClientChain, payableScheme, offeredSchemes, offeredNetworks, verdict }

triage.verdict is one word — branch on it:

VerdictMeaning
PAYABLE_RAILAt least one rail is on your chain and uses a scheme you’ve enabled.
UNPAYABLE_SCHEMEA rail is on your chain, but only via a scheme you haven’t enabled.
WRONG_CHAINRails exist, but none on your chain.
NO_RAILThe challenge offers no rails at all.

The fix follows directly from the verdict: UNPAYABLE_SCHEME means enable the scheme (for example, add 'exact' — see the exact buyer page); WRONG_CHAIN means pay from a client on one of the offered networks; NO_RAIL means the endpoint isn’t asking for payment in a form you can read.

The verdict is the headline; the rest of the struct is the evidence behind it.

interface ChallengeTriage {
onClientChain: boolean // any offered rail on your network?
payableScheme: boolean // any rail on your network using an enabled scheme?
offeredSchemes: PaymentScheme[] // distinct schemes the challenge offers
offeredNetworks: Caip2[] // distinct networks the challenge offers
verdict: ChallengeVerdict
}

offeredSchemes and offeredNetworks are the most useful for messaging — they tell the agent what the endpoint actually wants, which you can surface to the user or relay to a model:

if (triage.verdict === 'WRONG_CHAIN') {
console.log(`This endpoint takes: ${triage.offeredNetworks.join(', ')}`)
}
classifyChallenge(challenge, { network, schemes })
OptionTypePurpose
networkCaip2The chain your client is bound to, e.g. 'eip155:8453' or 'solana:…'.
schemesreadonly PaymentScheme[]The schemes you’ve enabled — 'onchain-proof' and/or 'exact'.

The schemes you pass should match the schemes your PipRailClient is configured with. The default is ['onchain-proof']; pass ['onchain-proof', 'exact'] if your client also enables the standard exact rail.

The two reads compose: classifyChallenge is the cheap, offline first pass that explains why a rail is or isn’t a candidate; planPayment() is the on-chain follow-up that says whether you can actually fund the candidate.

import { PipRailClient, parseChallenge, classifyChallenge } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
})
const url = 'https://api.example.com/report'
const res = await fetch(url) // a 402
const challenge = await parseChallenge(res)
if (!challenge) return // not an x402 challenge — nothing to triage
const triage = classifyChallenge(challenge, {
network: 'eip155:8453',
schemes: ['onchain-proof'],
})
if (triage.verdict !== 'PAYABLE_RAIL') {
return triage.verdict // wrong chain / scheme — no point reading the chain
}
const plan = await client.planPayment(url)
// only now do we touch RPC for balances, gas, recipient readiness
// → PaymentPlan | null

This ordering keeps the expensive, network-touching call off the path when the challenge was never payable in the first place.