SDK reference
Reachability
finder, sonar and resolver — and what the returned proofs are for.
finder(params)
Both steps, one call. Requires a credential unless you skip step 1.
const reach = await path.finder({
identifierType: 'phone',
identifier: '+12025550123',
});
reach.found; // true
reach.member; // "member-b"
reach.endpoint; // "https://api.member-b.com"
reach.proofs; // { sonar, resolver: null }Step 1 only, and the shape says so. SONAR answers which member holds this key — it does not return
a destination, so there is nothing for step 2 to resolve yet and accepts comes back empty.
Pass an address to get both halves in one call:
const reach = await path.finder({
identifierType: 'phone',
identifier: '+12025550123',
address: 'path:4a91c2f7e8d3',
});
reach.accepts; // [{ asset: "USDC", chain: "base" }, …]
reach.limits; // { max_single: "500000", currency: "USD" }
reach.proofs; // { sonar, resolver }The SDK will never send a raw identifier to a resolver. /resolver/{key} matches addresses, so the
call could not succeed anyway — but it would put a plaintext phone number in another member's URL,
and from there into their access logs. An identifier leaves this SDK hashed, or it does not leave.
Skipping step 1
const reach = await path.finder({
identifierType: 'address',
address: 'path:4a91c2f7e8d3',
knownEndpoint: 'https://api.member-b.com',
});No credential, no directory, no membership. The relationship profile.
Composition is client-side
The SDK makes both calls. It does not ask the operator to chain them, and that is deliberate: a network that fetched step 2 would see the destination's capability on top of every search — which rails a competitor supports, which assets, what limits, and how they change.
One extra round trip. What it buys is that no single party sees both halves.
Keep the proofs
await audit.record({
intent: 'send',
sonar_answer: reach.proofs.sonar,
resolver_answer: reach.proofs.resolver,
});Both are signed envelopes. When a payment lands somewhere unexpected, the question is what you were told before you sent — and a signed pair answers it where a log line does not.
Either can be null, and the type says so rather than pretending otherwise. sonar is null in the
relationship profile, where step 1 never runs; resolver is null when the holder is listed but
its endpoint did not answer — which is a real state a payer needs to tell apart from "nobody holds
this key".
When the holder is down
if (reach.found && reach.accepts.length === 0) {
// Someone holds this key; their endpoint did not answer.
}found: true with no capabilities means step 1 succeeded and step 2 did not. Distinct from
found: false, and worth telling a user differently: temporarily unreachable rather than
not found.
sonar(params)
Step 1 alone. Requires a credential.
const answer = await path.sonar({
identifierType: 'phone',
identifier: '+12025550123',
nonce: crypto.randomUUID(),
});Use it directly when you want to route without immediately resolving — deciding whether a transfer is internal or cross-member, for instance.
Every call consumes a reciprocity budget and is logged against your member. budget_exhausted is
not a rate limit: it is a monthly allowance tied to what you contribute to the index, and
retrying more slowly does not help.
Negatives are uninformative by design
answer.found; // false — unknown key, or known but not visible to youDo not build logic that tries to tell the two apart. A route that leaked the difference would answer "is this person a customer of somebody" to anyone patient enough to ask.
resolver(address, endpoint?)
Step 2 alone. No credential.
const answer = await path.resolver('path:4a91c2f7e8d3', 'https://api.member-b.com');
answer.standing; // "holder" — read this first
answer.accepts; // capabilities
answer.limits;
answer.commitment; // null when nothing was pledged — see belowRead standing before you read the terms
if (answer.standing === 'gateway') {
// These terms, limits and fees belong to the responder, not to the destination.
// The destination pledged nothing and does not know it is being addressed —
// which is why `commitment` is null and cannot be checked against anything.
}Both standings return the same shape, and that is exactly why the field has to be read: a caller that skips it shows a payer a middleman's price as though the receiver had quoted it.
Every answer is 'holder' today. A gateway is found through the member register rather than the
directory, and no operator declares PATH-FINDER.Gateway yet — see
PATH FINDER.
Check the commitment against the published record
import { checkCommitment } from '@pathprotocol/sdk';
const published = await path.addressCommitment('path:4a91c2f7e8d3', 'https://api.member-b.com');
const check = checkCommitment({
address: 'path:4a91c2f7e8d3',
accepts: answer.accepts,
limits: answer.limits,
answerCommitment: answer.commitment,
publishedCommitment: published.commitment,
});check.status is committed, uncommitted, or mismatch. Fetch published yourself rather than
taking it from whoever handed you the answer — comparing an answer against a digest that arrived
with it compares a statement with itself.
Omit endpoint to ask the client's own operator.
Verify before sending
import { verifyAgainstIssuer } from '@pathprotocol/sdk';
const { valid } = await verifyAgainstIssuer(answer, 'https://api.member-b.com');
if (!valid) throw new Error('Refusing to send against an unverified answer');Revoked is not unknown
try {
await path.resolver(address, endpoint);
} catch (err) {
if (err instanceof PathApiError && err.code === 'path.address.revoked') {
// The address moved. Ask the recipient for a current one.
}
}revoked means ask for a new address. not_found means you have the wrong destination. Telling
a user the wrong one of those sends them to the wrong place for help.