Cookbook
A QR code at the counter
Static and dynamic codes, reading them without opening an attack surface, and the size limit that decides whether a sticker actually scans.
Static: a printed code
One code, printed once, taped to the counter. The payer enters the amount. The printed form is HTTPS — a custom scheme that no app handles is a dead sticker.
import { buildUri, signUri } from '@pathprotocol/sdk';
const unsigned = buildUri({
kind: 'pay',
reference: '4a91c2f7e8d3', // opaque id, not path:4a91c2f7e8d3
host: 'pay.example.com',
currency: 'XOF',
memo: 'Boutique Ndiaye',
});
// https://pay.example.com/p/pay/4a91c2f7e8d3?currency=XOF&memo=Boutique%20Ndiaye
const uri = signUri(
{
kind: 'pay',
reference: '4a91c2f7e8d3',
host: 'pay.example.com',
kid: 'op_example_2026_01',
params: { currency: 'XOF', memo: 'Boutique Ndiaye' },
},
issuerPrivateKey,
);Keep a signed static URI under roughly 300 characters on the HTTPS form. That form costs
twenty to forty characters more than the path: alias. Past 300 a QR code needs a higher version
and loses error correction, and a printed sticker stops scanning in dim light, at an angle, on a
scratched surface — exactly the conditions it exists for.
The path: alias is for app-to-app and NFC. op= is allowed there only, as a routing hint — not
as a reprint of an institution slug.
Dynamic: a till showing an amount
The common case. It is a PATH URI (two segments). The terms live behind the reference, so the code stays short. It is unreadable offline — without the issuer there is nothing to confirm.
const request = await path.createRequest(
{ amount: total.toFixed(0), currency: 'XOF', orderReference: sale.id },
sale.id,
);
display(`https://pay.example.com/p/request/${request.reference}`);A one-segment /p/<code> is an inherited payment link, not this. Do not guess a kind onto it.
Reading one safely
import {
parsePayload,
issuerOrigin,
pillarFor,
InteropParseError,
PathClient,
} from '@pathprotocol/sdk';
export async function onScan(payload: string, knownHolderBase?: string) {
let uri;
try {
uri = parsePayload(payload);
} catch (err) {
if (err instanceof InteropParseError) {
switch (err.reason) {
case 'not_path':
return tryOtherFormats(payload); // EMVCo, BIP-21, a one-segment /p/code
case 'unknown_kind':
return { error: 'This code is newer than this app. Update to continue.' };
case 'malformed':
return { error: 'This code is damaged.' };
}
}
throw err;
}
// The host *is* the routing index. A host not on the issuer's hosts[] is not that issuer.
const base = issuerOrigin(uri) ?? knownHolderBase;
if (!base) return { error: 'This alias has no host — you already need to know the holder.' };
const wallet = new PathClient({ baseUrl: base });
switch (pillarFor(uri.kind)) {
case 'REQUEST': {
const request = await wallet.readRequest(uri.reference);
return { screen: 'confirm-payment', request };
}
case 'ADDRESS': {
const answer = await wallet.resolver(uri.reference);
return { screen: 'enter-amount', accepts: answer.accepts, params: uri.params };
}
case 'CONNECT':
return { screen: 'review-permission', reference: uri.reference };
case 'SETTLEMENT':
return { screen: 'verify-receipt', reference: uri.reference };
case 'ID':
return { screen: 'present-identity', reference: uri.reference };
}
}Four things this gets right.
parsePayload, not parseUri. A printed code is HTTPS. parseUri is the strict path:
parser and will reject it.
There is no resolveIssuerBase. HTTPS already named the host. The alias did not, and that is
intentional: removing the routing hint does not invent a new place to fetch from.
not_path is not an error. In a general-purpose scanner it means "this was something else" —
including a one-segment inherited link — fall through rather than showing a failure.
unknown_kind names the problem. Update to continue is actionable; invalid code is not.
Why anchored parsing matters here
parsePayload('Pay at Boutique Ndiaye https://pay.example.com/p/pay/ATTACKER trailing');
// throws — not_pathA reader that scans for a pattern anywhere and takes the first match can be redirected by appending a crafted suffix. Printed codes get replaced with stickers; that is a real attack.
parsePayload anchors at both ends. If you write your own reader, do the same.
Presented codes
DRAFT as a product flow. Kind claim,
mode presented — the CPM rules, not a second axis.
const uri = buildUri({
kind: 'claim',
reference: shortLivedClaim,
host: 'pay.example.com',
});Useful where the merchant has a scanner and the customer has no connectivity. One use, 60–180 seconds, no amount inside, screenshot blocked.
Falling back to what is already there
Map native formats to PATH kinds. The interop table is the source — do not maintain a second copy here.
A reader that understands PATH plus one native format covers most of what it will meet in the field.