GitHub

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=openconnect

n= 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/7fk2m9pq3vx8

Unreadable 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_path

not_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.

On this page