GitHub

Cookbook

Pay across members

Send to someone at another institution, starting from a phone number — with the failure cases that occur in practice.

The flow a directory exists for: your customer knows a phone number, the recipient banks somewhere else, and neither of you has integrated the other.

Needs a member credential for step 1. Step 2 does not.

The flow

import { PathClient, PathApiError } from '@pathprotocol/sdk';

export async function prepareTransfer(input: { phone: string; amount: string; currency: string }) {
  let reach;
  try {
    reach = await path.finder({ identifierType: 'phone', identifier: input.phone });
  } catch (err) {
    if (err instanceof PathApiError && err.code === 'path.finder.budget_exhausted') {
      // Not a rate limit. Retrying slower changes nothing.
      throw new Error('Directory allowance exhausted for this month');
    }
    throw err;
  }

  if (!reach.found) {
    return { reachable: false, reason: 'no-match' };
  }

  if (reach.accepts.length === 0) {
    // Someone holds this key; their endpoint did not answer.
    return { reachable: false, reason: 'holder-unavailable', member: reach.member };
  }

  const option = chooseRail(reach.accepts, input.currency);
  if (!option) {
    return { reachable: false, reason: 'no-common-rail', accepts: reach.accepts };
  }

  return {
    reachable: true,
    member: reach.member,
    via: option,
    limits: reach.limits,
    proofs: reach.proofs,
  };
}

The four outcomes, and why each needs its own message

OutcomeWhat to tell the user
no-match"We could not reach this number." Nothing more — see below
holder-unavailable"Temporarily unreachable, try again shortly." Someone does hold it
no-common-rail"They accept X and Y; you can send neither." Actionable
reachableProceed

For no-match, tell the user nothing beyond "we could not reach this number". Not "they are not registered", not "try again once they sign up". The directory deliberately cannot distinguish a key that does not exist from one that is not visible to you, and a message that implies otherwise turns your app into the oracle the protocol works to avoid.

Keep the proofs

await ledger.recordIntent({
  order: order.id,
  destination_member: reach.member,
  sonar_answer: reach.proofs.sonar,
  resolver_answer: reach.proofs.resolver,
});

Both are signed envelopes. When a transfer lands somewhere unexpected, the question is what you were told before you sent. A signed pair answers it; an application log does not.

Check the commitment before sending

import { verifyAgainstIssuer } from '@pathprotocol/sdk';

const resolverAnswer = reach.proofs.resolver!;
const { valid } = await verifyAgainstIssuer(resolverAnswer, reach.endpoint!);
if (!valid) throw new Error('Refusing to send against an unverified answer');

A compromised resolver can lie once. A signed answer means the lie is on the record with a key against it, and a commitment mismatch means you refuse before any value moves.

Respect the limits

const max = reach.limits.max_single as string | undefined;
if (max && BigInt(input.amount) > BigInt(max)) {
  return { blocked: 'above-recipient-limit', max };
}

Limits come from the recipient's standing intent. Ignoring them means a transfer the recipient will bounce, which costs both of you a support conversation.

The version that needs no directory

If you already know where the recipient banks — a platform and its provider, two institutions with an agreement — skip step 1 entirely:

const anyone = new PathClient({ baseUrl: 'https://api.member-b.com' });
const answer = await anyone.resolver('path:4a91c2f7e8d3');

No credential, no membership, no budget consumed. This is the relationship profile, and it is a complete path — the proof that the open part of the protocol is genuinely open.

What not to do

Do not look up in bulk to warm a cache. Every call spends budget and is logged against your member. Look a number up when a user actually intends to pay it — see Contact lookup, responsibly.

Do not call the directory from a mobile app. The credential would be public. Your app talks to your backend; your backend talks to the directory.

Do not cache a negative for long. People join. A cached "unreachable" that outlives the fact is a customer told, repeatedly, that their friend cannot be paid.

On this page