Cookbook
Contact lookup, responsibly
Showing a user who they can pay, without sending an address book to a directory.
Every payment app wants to show which contacts are reachable. The straightforward implementation — upload the address book on install, look everything up, cache the result — is also the most exposing thing an application can do with a directory.
There is a better shape, it is not harder to build, and it costs nothing in functionality.
What the obvious version costs
A user installs your app. It reads 800 contacts and looks up all of them.
The directory learns all 800. Most of those people will never be paid by this user. They did not install anything, and they are now the subject of a query recorded against your member.
Your budget is gone. 800 lookups per install, and the allowance is monthly and tied to what you contribute to the index.
The cache goes stale in the wrong direction. People join. A cached "unreachable" outlives the fact and your user is told, repeatedly, that a friend who signed up last week cannot be paid.
Look up at the moment of intent
export async function onRecipientChosen(userId: string, phone: string) {
const cached = await reachCache.get(phone);
if (cached && !isStale(cached)) return cached;
const reach = await path.finder({ identifierType: 'phone', identifier: phone });
await reachCache.set(phone, reach, {
ttl: reach.found ? DAYS(7) : HOURS(6),
});
return reach;
}The user picks someone to pay; you look that one person up. Same feature from the user's side, one lookup instead of eight hundred.
Asymmetric cache lifetimes matter. A positive is stable — people rarely leave. A negative is not: someone unreachable this morning may have signed up by lunch. Six hours is generous for a negative; a week is fine for a positive.
If you must pre-fetch, bound it
Some products genuinely need a "who can I pay" list up front. Then bound it by evidence of intent:
export async function warmFrequentContacts(userId: string) {
const candidates = await contacts.mostInteracted(userId, { limit: 25, since: DAYS(90) });
const results = await path.sonarBatch(
candidates.map((c) => ({ identifierType: 'phone' as const, identifier: c.phone })),
);
return results.filter((r) => r.found);
}Twenty-five contacts the user has actually interacted with, not eight hundred numbers their phone happens to hold. Thirty-two times less exposure, and the list is more useful because it is ordered by something real.
This is the highest-impact privacy measure in the protocol, and it contains no cryptography. It is a change of timing.
Tell the user the truth about failures
if (!reach.found) {
return { canPay: false, message: 'We could not reach this number.' };
}Nothing more than that. Not "they are not registered", not "invite them", not "try again once they sign up". The directory cannot distinguish a key that does not exist from one that is not visible to you, and a message implying otherwise turns your app into the oracle the whole design avoids — someone can then use your interface to test whether any phone number belongs to a customer somewhere.
holder-unavailable is different and worth its own message: someone does hold this key and their
endpoint is down. "Temporarily unreachable, try shortly" is accurate; "not found" is not.
Ask before reading contacts
Obvious, and worth stating because the directory makes it consequential: a user consenting to "access contacts" is not consenting to have their contacts looked up in a payment directory. Say what the lookup is for, and let them pick recipients manually if they decline.
Handling the budget
try {
reach = await path.finder({ identifierType: 'phone', identifier: phone });
} catch (err) {
if (err instanceof PathApiError && err.code === 'path.finder.budget_exhausted') {
// Not a rate limit. Retrying slower does nothing — the allowance is monthly.
metrics.increment('path.budget_exhausted');
return { canPay: null, message: 'Recipient lookup is temporarily unavailable.' };
}
throw err;
}If you hit this regularly, the answer is fewer lookups rather than a retry policy. Instrument it: a
rising budget_exhausted count is usually a pre-fetch someone added without noticing what it cost.
Never from the client
The credential stays on your server. An app that calls a directory directly ships a key that anybody can extract from the binary — and every lookup it makes is attributed to your member.