Skip to content

Discover & register

Discovery is two read/write moves against the open x402 directories that already exist, PipRail hosts none of them. client.discover({ query }) reads them to find payable resources; client.register(url) lists a resource you run. Both are $0, move no funds, and never throw for a read/transport problem: a dead or changed index simply contributes nothing.

discover() reads the open indexes, merges and dedupes them by resource URL, and by default returns only resources payable on this client’s chain. Each result is a DiscoveredResource carrying its advertised rails[], so a chosen resource feeds straight into the read-only trio.

import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { key: process.env.AGENT_KEY! },
})
const found = await client.discover({ query: 'weather' })
// → DiscoveredResource[], or [] if every index is down or empty (never throws)
for (const r of found) {
// priceUsd is the index's advertised figure when it reports one, and is often absent
console.log(r.resource, r.priceUsd ?? '(no advertised price)', r.source)
// feed r.resource into quote() / planPayment() to confirm + pay
}

Pinpoint search: fan-out + relevance ranking

Section titled “Pinpoint search: fan-out + relevance ranking”

A multi-word query used to return nothing. 402 Index’s own ?q= is AND-tokenized: it matches only listings whose text contains every word verbatim, so a query like 'crypto price feed' missed a “Live BTC/USD oracle” listing that obviously answers it. discover() now closes that gap in three moves, so a natural-language query lands on the right resource:

  1. Fan-out. For a multi-word query it issues one request per word to 402 Index (plus the full phrase), capped at 5 requests, and unions the hits, so a listing that matches each word somewhere is found even though no single field contains the exact phrase.
  2. Relevance ranking. The merged set is ranked client-side by a weighted score (name > category/tags > URL path > description, with a big bonus when all query tokens match), so the most relevant resource sorts first. The score lands on each result as result.score (present only when a query was given).
  3. Server-side filters. The category / asset / verified / paymentValid filters below are pushed to 402 Index so the index does the narrowing where it can.
// Multi-word, filtered, sorted, to pinpoint a reliable finance feed:
const feeds = await client.discover({
query: 'crypto price feed',
category: 'finance',
minReliability: 80,
sort: 'reliability',
})
// → DiscoveredResource[] ranked by reliability, only finance-categorized, ≥ 80 health

Then pipe a result into quote()planPayment()fetch() to actually pay it.

OptionDefaultPurpose
querynoneFree-text. Tokenized + matched against name / category / tags / URL path / description; a multi-word query fans out across 402 Index (one request per word) and the merged set is relevance-ranked.
network'self''self' (this client’s chain), 'any' (every chain), or a CAIP-2 id / chain slug like 'base'.
categorynoneKeep only this category (prefix match, e.g. 'ai'). Strict: a result the index didn’t categorize is dropped, so real category matches aren’t drowned by uncategorized ones. Pushed to 402 Index server-side.
assetnoneKeep only resources paying in this token symbol, e.g. 'USDC'. Keeps results whose asset the index didn’t report (confirm with quote()).
maxPricenoneDrop results whose advertised price exceeds this number. Results with no advertised price pass through.
minReliabilitynoneDrop results whose reliability score (0 to 100) is below this. Unscored results (e.g. from Bazaar) pass through.
verifiednonePrefer verified listings (402 Index server-side only). Its verified flag differs from the per-record domain_verified, so it is not re-filtered client-side; inspect result.verified for the per-record signal.
paymentValidnoneRestrict to listings 402 Index confirmed are payable x402 (its payment_valid flag).
sort'relevance'*'relevance' | 'reliability' | 'price' | 'uptime' | 'name' (type DiscoverySort). *Defaults to 'relevance' when a query is given, else first-seen order.
order'desc'Direction for a non-relevance sort.
sources['bazaar', '402index', 'circle']Which open indexes to read. All three are free and keyless.
limit20Max results returned per index, paged transparently (see below). A multi-word query fans out into several requests, so the merged total before dedupe can exceed it.
exhaustivefalseRead each source’s whole catalog, bounded only by maxRequests. Overrides limit.
maxRequests12Hard ceiling on HTTP requests per source, per call.

network: 'self' is the useful default: it returns only what this wallet can actually pay, matched via the bound driver’s own supports() so it works on every family, including custom chains. A rail whose network can’t be resolved is kept rather than hidden, because discovery is never silently empty on an unmapped chain.

// Look across all chains, then decide later with planAcross()
const all = await client.discover({ query: 'image', network: 'any', maxPrice: 1 })
// → DiscoveredResource[] across every chain (filter/plan locally)
interface DiscoveredResource {
resource: string // the gated URL. Quote or pay this
source: DiscoverySource // which index surfaced it ('bazaar' | '402index' from discover())
name?: string
description?: string
category?: string
tags?: string[] // free-text keywords, when the index reports them
priceUsd?: number // advertised price, when the index reports one (402 Index)
reliabilityScore?: number // health/uptime score 0 to 100 (402 Index only; absent on Bazaar)
health?: string // liveness as last probed: 'healthy' | 'degraded' | 'down' (402 Index)
verified?: boolean // per-record domain-ownership signal (402 Index domain_verified)
score?: number // relevance score, present only when a query was given
rails: DiscoveredRail[] // the advertised payment options (cross-scheme)
}

reliabilityScore, health, and verified are reported by 402 Index and absent from sources that don’t measure them (Bazaar), so treat a missing field as “unknown,” not “bad”. The minReliability filter and the unscored-pass-through rule are built around exactly that.

limit is the number of results you get back, not the size of one HTTP request. The SDK asks each index for min(limit, that index's page ceiling) rows and walks offset until your limit is met, the catalog runs out, or the request budget is spent.

This matters more than it sounds. Every index caps page size, and they cap silently. You ask for 1,000 rows, you are handed 50, and nothing in the response says so. Before pagination existed, a default discover() returned 97 results against catalogs holding 14,627 (Bazaar), 1,246 (Circle) and 106,398 (402 Index). An agent choosing what to buy was choosing from well under one percent of what was on offer, and had no way to tell.

await client.discover({ query: 'weather' }) // ~20/source, 1 request each
await client.discover({ query: 'weather', limit: 2000 }) // pages until it has 2000
await client.discover({ network: 'any', exhaustive: true }) // the whole catalog
await client.discover({ network: 'any', exhaustive: true, maxRequests: 40 }) // deeper still

Queries are answered by the indexes, not just filtered locally

Section titled “Queries are answered by the indexes, not just filtered locally”

A query is pushed to each index that can answer one, and only filtered locally where an index cannot:

IndexHow a query is answered
CDP BazaarIts semantic search endpoint (searchMethod: 'hybrid'), unioned with a local token filter over the paged list.
CircleServer-side query, plus category, asset and maxUsdPrice at the index.
402 IndexServer-side, with a per-token fan-out for multi-word queries.

Bazaar’s search matches meaning rather than substrings. "forecast temperature" finds an endpoint named /forecast, and "what is the price of ethereum" finds an ETH price feed, in neither case because a word matched.

It is used as a precision pass on top of the paged list, never instead of it: the endpoint caps at about 20 rows and does not paginate, so on its own it would make a deep query-shaped search shallower. The two are unioned and deduped, with the semantic record preferred where a resource appears in both, because it carries a name and description the list endpoint omits.

Results the index selected are marked indexMatched: true. That flag is load-bearing rather than informational: the local ranker scores by token overlap and drops anything scoring zero, which would throw away exactly the results worth having. A feed described as “live ETH to USD” shares no word with “what is the price of ethereum”. The index understood the question, so the result is kept and ranked, never filtered away for lack of a matching substring.

IndexRows per requestOver-limit behaviourCatalog size (2026-09-10)
CDP Bazaar1000silently capped14,627 resources
402 Index200silently capped106,398 services
Circle200HTTP 4001,246 resources

Page one is fetched alone, because it carries the catalog’s total. The remaining offsets are then known without reading it, so they go out in parallel. A deep read is one round-trip deeper than a shallow one, not N.

Those parallel pages are issued in bounded waves of 6 per index. A full read of 402 Index is 500+ requests, and firing those at once would turn a well-meaning agent into a denial-of-service against a free, unauthenticated directory, and get PipRail’s User-Agent blocked for everyone. The walk also stops the moment your limit is satisfied, rather than finishing a wave it no longer needs.

maxRequests (default 12) is the hard stop: at most 12 requests per source per call, which is roughly 12,000 Bazaar rows or 2,400 from 402 Index. Raise it deliberately.

Discovery reads never throw. A page that errors, times out, or changes shape contributes [], and the pages that did land are still returned, because a half-read catalog is more useful to an agent than an exception. The same rule applies per source: if Bazaar is down, Circle and 402 Index still answer.

One consequence worth knowing: because reads never throw, a source misconfigured badly enough to fail every page simply vanishes from your results in silence. That is the specific failure Circle’s 400-on-over-limit could cause, and why its ceiling is pinned by a test.

Server-side, discovery needs no setup at all. Node, an MCP server, a worker, an agent framework: discover() reads the indexes directly and always has.

In a browser it cannot, and the reason is worth stating precisely because it is not something the SDK can fix. None of the open indexes sends a usable Access-Control-Allow-Origin header. 402 Index and CDP Bazaar send none at all; Circle sends one twice ('*, *'), which browsers reject. CORS is enforced by the browser, so no library, option, header or request mode reads those catalogues from a page.

What the SDK does instead is carry its own forwarder and find it by itself.

Mount indexProxyHandler() on any route your app already serves:

// Any Request → Response runtime: Netlify, Cloudflare, Deno, Bun, Hono, a Next route handler…
import { indexProxyHandler, INDEX_PROXY_PATH } from '@piprail/sdk'
export default indexProxyHandler()
export const config = { path: INDEX_PROXY_PATH } // '/api/x402-index'

There is no client-side configuration. In a browser the SDK probes that conventional path once, uses it when something answers, and reads the indexes directly when nothing does. A missing route costs one request, remembered for the life of the process.

The handler is deliberately boring: GET only, a fixed allowlist of index hosts (INDEX_PROXY_ALLOWED_HOSTS), https only, no credentials, no request body, and the upstream status passed through unchanged so a dead index still reads as dead rather than as “nothing matched”. IndexProxyOptions takes allowHosts, timeoutMs and cacheControl.

Serving it elsewhere, or routing through something you already run? Pass fetchImpl and the SDK uses it for every index read, including each page of a deep search and the semantic pass:

const client = new PipRailClient({
chain: 'base',
fetchImpl: (url, init) => fetch(`/my/route?url=${encodeURIComponent(String(url))}`, init),
})

Reads only. register() never goes through it, because a write should be deliberate about where it is sent.

It would be less work for everyone if the SDK defaulted to a proxy we ran, and that is exactly the shape this project exists to avoid. It would put us in the path of every user’s searches, and make a rail that works without us depend on us. You run the forwarder, or you run server-side and need none.

register() lists a resource on the open registries so agents can find it. The default target is 402 Index, in one POST, with no auth, no signature, and no payment. It returns one RegisterOutcome per target; a target the chain can’t satisfy comes back { ok: false, detail }, never a throw.

const [outcome] = await client.register('https://api.example.com/report', {
name: 'Daily report',
category: 'finance', // ← the #1 findability lever (see below)
tags: ['market', 'stocks', 'daily report'], // words an agent will search for
description: 'Daily US equity market report.',
priceUsd: 0.1, // advertised metadata only; no oracle reads this
asset: 'USDC',
})
console.log(outcome.ok, outcome.visibility, outcome.note)
// → true 'pending-review' '402 Index probes your URL on submit … becomes searchable once it
// passes automated health + payment checks … verify your domain for instant approval + a badge.'
OptionDefaultPurpose
categorynoneThe field that moves the needle. A real category ('ai', 'finance', 'data', …) makes a listing rank + filter where most of the uncategorized catalog can’t.
tagsnoneKeywords. Folded into the description as a searchable · Keywords: … tail (402 Index search is literal) and sent as a tags field.
namethe URL’s hostDisplay name for the listing.
descriptionnoneListing description. The one field an index displays, so pack the words agents search for into it (and tags).
priceUsdnoneAdvertised price (metadata only; no oracle reads it).
assetnonePayment asset symbol, e.g. 'USDC'.
networkthe client’s chainPayment network slug, e.g. 'base'.
method'GET'HTTP method the resource answers on.
providernoneWho runs the resource (provider/org name).
contactEmailnoneContact email for the listing (also used by the domain claim).
probeBodynoneA JSON request body the index sends when health-checking a POST/PUT resource, so probes pass and the reliability score stays high.
targets['402index']Which indexes to list on. Add 'x402scan' for the SIWX path.
attributiontrueAttribute the listing to PipRail (the via field + a tasteful · Built with @piprail/sdk on the description). Metadata only; opt out with attribution: false. See Attribution.

Listing is asynchronous, so each outcome carries a visibility and a one-line note. Don’t read ok: true as “searchable now.”

interface RegisterOutcome {
source: DiscoverySource
ok: boolean
status?: number // HTTP status, when a request was made
detail?: string // success summary or the reason it didn't list
listingUrl?: string
visibility?: ListingVisibility // 'live' | 'pending-review' | 'not-listable'
note?: string // agent-readable caveat for this source
}
visibilityMeaning
'live'Findable now, so search it immediately.
'pending-review'Accepted and probed, but not instantly searchable. It becomes findable once it passes the index’s automated checks (or instantly, if your domain is verified). Retry discover() later.
'not-listable'It didn’t list, either a failure or because this index structurally can’t list a PipRail resource.

The honest answer, measured against the live demo rather than a marketing number:

PathWhat happensWhen it’s searchable
Self-register (default)402 Index probes your URL on submit, then runs automated health + payment-validity checks.Once it passes the checks, with no domain verification required.
Verify your domainServe one hash file, call verifyDomain() (see below).Instant and guaranteed. It flips every pending listing on that domain live at once, with a domain_verified badge.

The real data point (facts, not a marketing number). PipRail’s own live demo, piprail.com/x402/demo, was self-registered on 402 Index on 2026-06-09 with no domain verification (domain_verified: 0), and is confirmed searchable. client.discover({ query: 'piprail' }) returns it, with health_status: healthy, x402_payment_valid: 1, reliability_score: 90. So a healthy, genuinely-payable endpoint does become discoverable on the self-register path with no verification step (402 Index doesn’t expose the exact probe-to-search latency). If you need a guaranteed, immediate go-live, verify your domain.

// The exact call that finds the live demo today: register → discover, end to end:
const found = await client.discover({ query: 'piprail' })
// → [{ resource: 'https://piprail.com/x402/demo', source: '402index', priceUsd: 0.01,
// name: 'PipRail x402 demo', rails: [ { network: 'eip155:8453', … } ] }]

To go live immediately instead of waiting on the probe, verify your domain in two calls, no funds:

const claim = await client.claimDomain('https://api.example.com/report')
// serve claim.verificationHash as the body of claim.verificationUrl
// (your /.well-known/402index-verify.txt), then:
const res = await client.verifyDomain('api.example.com')
// → { ok: true, status: 'verified' }. Your listings on that domain are now live

Attribution: how a listing is associated with PipRail

Section titled “Attribution: how a listing is associated with PipRail”

By default, a listing you register is attributed to PipRail, the same unobtrusive “Made with X” marker tools like Swagger and Hugo add, so the SDK spreads as endpoints get found. It’s two things, both metadata only (they never change how your resource is paid, ranked, or found):

  • a via: '@piprail/sdk' provenance field on the registration payload, and
  • a compact · Built with @piprail/sdk appended to your listing description, the one field an index actually displays.

It’s tasteful by construction: it never double-stamps a description that already mentions PipRail, never fabricates a description you didn’t provide, and never pushes one past a sane length cap. The request User-Agent (@piprail/sdk (+https://piprail.com)) carries PipRail on every call regardless.

// Default, attributed:
await client.register(url, { description: 'Real-time weather by lat/lon.' })
// description listed as: "Real-time weather by lat/lon. · Built with @piprail/sdk"
// Opt out, and your listing is untouched:
await client.register(url, { description: 'Real-time weather by lat/lon.', attribution: false })

The per-source lifecycle facts live in DIRECTORY_INFO (importable), so an agent can reason about an index (auth, chains, whether discover() reads it) without embedding directory knowledge. getDirectoryInfo(source) returns one DirectoryInfo:

import { getDirectoryInfo } from '@piprail/sdk'
const info = getDirectoryInfo('402index')
info.auth // 'none'
info.readByDiscover // true: discover() reads 402 Index
info.onSuccess // 'pending-review'
info.review // 'probe-sync': a synchronous URL probe (not facilitator-coupled)
SourceRead by discover()Write authNotes
bazaaryesno (facilitator-only)Free to read. Can’t be written to, because Bazaar catalogs only what its own facilitator settles, and PipRail uses none.
402indexyesnoneThe primary register target: one POST, no auth. Probed on submit, then searchable once it passes automated checks; verify your domain for instant approval.
x402scannoSIWXBase/Solana only; needs one wallet signature and a resolvable input schema. A live listing here won’t appear in discover().

Adding 'x402scan' to targets lists via Sign-In-With-X: one wallet signature, facilitator-free, but Base/Solana-only and EVM signing today. It needs a discoverySigner (the EVM families have one); a chain family without one returns { ok: false, detail } rather than throwing.

const outcomes = await client.register('https://api.example.com/report', {
targets: ['402index', 'x402scan'], // x402scan needs an EVM signer + a Base/Solana rail
})
// → RegisterOutcome[], one per target, in target order
for (const o of outcomes) {
console.log(o.source, o.ok, o.visibility) // e.g. 'x402scan' true 'live'
}

The open SIWX handshake is a moving convention, so validate against x402scan before relying on it. x402scan also requires a resolvable input schema, which you supply by emitting an /openapi.json or the extensions.bazaar block in your 402 body.