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[] — [] 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 — 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 — 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
queryFree-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'.
categoryKeep 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.
assetKeep only resources paying in this token symbol, e.g. 'USDC'. Keeps results whose asset the index didn’t report (confirm with quote()).
maxPriceDrop results whose advertised price exceeds this number. Results with no advertised price pass through.
minReliabilityDrop results whose reliability score (0–100) is below this. Unscored results (e.g. from Bazaar) pass through.
verifiedPrefer 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.
paymentValidRestrict 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']Which open indexes to read.
limit20Max results to fetch per index request (default 20) — a multi-word query fans out into several, so the merged total before dedupe can exceed it.

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 — 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/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–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.

register() lists a resource on the open registries so agents can find it. The default target is 402 Index — one POST, no auth, no signature, 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
categoryThe highest-leverage findability field. A real category ('ai', 'finance', 'data', …) makes a listing rank + filter where most of the uncategorized catalog can’t.
tagsKeywords. 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.
descriptionListing description. The one field an index displays — pack the words agents search for into it (and tags).
priceUsdAdvertised price (metadata only — no oracle reads it).
assetPayment asset symbol, e.g. 'USDC'.
networkthe client’s chainPayment network slug, e.g. 'base'.
method'GET'HTTP method the resource answers on.
providerWho runs the resource (provider/org name).
contactEmailContact email for the listing (also used by the domain claim).
probeBodyA 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 — 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 — a failure, or this index structurally can’t list a PipRail resource.

The honest answer, measured against the live demo — not 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 — no domain verification required.
Verify your domainServe one hash file, call verifyDomain() (see below).Instant + guaranteed — and 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 — 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 — your listing, 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
bazaaryes— (facilitator-only)Free to read. Can’t be written to — 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 — 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.