Cookbook
A QR code at the counter
Printing a code that opens, reading one without opening an attack surface, and finding out which network is behind it.
A PATH code is a link. One form, whatever carries it.
https://<host>/path/<reference>Thirty-nine characters for a complete code. At that length a QR stays at a low version with high error correction — which is what keeps a sticker scanning in dim light, at an angle, on a scratched surface.
Static: a printed code
One code, printed once, taped to the counter. It points at a destination; the payer enters the amount.
import { buildLink } from '@pathprotocol/sdk';
const address = await path.issueAddress({ subjectId: shop.subjectId });
buildLink({
host: 'pay.example.com',
reference: address.reference, // '4a91c2f7e8d3' — the bare id, not 'path:4a91c2f7e8d3'
network: 'openconnect',
});
// https://pay.example.com/path/4a91c2f7e8d3?n=openconnectn= is a hint. It lets a wallet render a waiting state or pick a cached rulebook. It is not
evidence of anything — anyone can print a sticker claiming any network.
No amount, no currency, no memo in the link. buildLink throws on them. A figure in a query string
is editable with a text editor, and terms belong in the signed object where a signature covers
them.
Dynamic: a till showing an amount
The common case. Same link shape; a different object behind it.
const request = await path.createRequest(
{
amount: { value: total.toFixed(0), asset_type: 'fiat', asset_code: 'USD' },
accepts: [{ rail: 'book', asset_type: 'fiat', asset_code: 'USD' }],
orderReference: sale.id,
},
sale.id,
);
display(request.url); // https://pay.example.com/path/7fk2m9pq3vx8Unreadable offline, and that is the trade. The link carries a reference, not the terms. Without the issuer a reader can recognise a PATH code and refuse politely; it cannot invent an amount.
A checkout session works identically — createCheckoutSession returns a url in the same
grammar, and the object behind it adds the merchant hand-back.
Reading one safely
import {
InteropParseError,
PathClient,
discoveryUrl,
isPathObjectType,
parseLink,
} from '@pathprotocol/sdk';
export async function onScan(payload: string) {
// 1–6 — is this a PATH link at all? No network call.
let link;
try {
link = parseLink(payload);
} catch (err) {
if (err instanceof InteropParseError) {
return err.reason === 'not_path'
? tryOtherFormats(payload) // EMVCo, BIP-21, an operator's own link
: { error: 'This code is damaged.' };
}
throw err;
}
const wallet = new PathClient({ baseUrl: link.origin });
// 7 — the chain of trust. The host declares; the register confirms.
const config = await wallet.discoveryAt(link.origin);
if (!isListedInRegister(config.network?.slug, config.operator)) {
return { error: 'This code claims a network that does not list its issuer.' };
}
// 8 — the object says what it is.
const object = await wallet.readObject(payload);
if (!isPathObjectType(object.type)) {
return { error: 'This code is newer than this app. Update to continue.' };
}
switch (object.type) {
case 'path.address': return { screen: 'enter-amount', accepts: object.accepts };
case 'path.request':
case 'path.checkout': return { screen: 'confirm', object, issuer: config.operator.slug };
case 'path.receipt': return { screen: 'verify-receipt', object };
default: return { screen: 'update-required' };
}
}Five things this gets right.
parseLink is anchored. The whole string must be a PATH link. Surrounding text is not a link:
parseLink('Pay at Oak Street Books https://pay.example.com/path/7fk2m9');
// throws — not_pathnot_path is not an error. In a general-purpose scanner it means this was something else —
fall through rather than showing a failure.
The register check is not optional. Without it, n= and the discovery document are both
self-assertions, and any domain can call itself a member of any network. This is the step
implementations skip.
The type comes from the object. The URL never named it, so there is nothing to disagree with. An unfamiliar type is an upgrade prompt — update to continue is actionable, invalid code is not.
path.checkout lands on the same screen as path.request. Checkout only adds a redirect at
the end. A wallet that can pay one pays the other without new code.
Hosts that do not carry the marker
An operator with millions of printed stickers will not reprint them, and does not have to.
const res = await fetch(discoveryUrl(new URL(scanned).origin));
if (res.ok) {
// This host speaks PATH even though the link is not marker-shaped.
}The marker is the fast path, not an admission requirement.
Presented codes
DRAFT as a product flow. Type path.claim
— the payer displays, the merchant scans. One use, 60–180 seconds, no amount inside the code, and
never left in a shareable screenshot.
Falling back to what is already there
Map native formats to PATH types. 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.