GitHub

Docs

PATH INTEROP

One URI, three transports — HTTPS on the printed code, path: as an app-to-app alias, and a reading algorithm that does not become an attack surface.

LIVE — the HTTPS grammar, the path: alias, parsePayload, and URI signatures are implemented. claim, present and receipt kinds are DRAFT as product flows; their rules are already in this page.

One grammar, three transports

Fig. 01 — Canonical form
https://<host>/p/<kind>/<ref>?<params>
schemeroutingkindreferenceparams
HTTPS is what a QR encodes. path: is an alias, not a second grammar.

A QR is not a format. A deeplink is not a format. An NFC tag is not a format. They are three ways to carry the same object.

Printed and clickable form — HTTPS:

https://<host>/p/<kind>/<reference>?<params>

A custom path: scheme fails silently when no app claims it. HTTPS always degrades: it opens a page. With Universal Links and App Links it opens the app when one is installed.

App-to-app / NFC alias — never the printed form:

path:<kind>/<reference>?<params>

op= is allowed only on this alias. The HTTPS host carries routing. Removing the routing hint does not make the object unresolvable for a party that already knows the holder.

The reference is opaque [A-Za-z0-9_.-]+. A colon is not allowed, so path:pay/path:4a91c2f7e8d3 cannot nest. The address path:4a91c2f7e8d3 (PATH ADDRESS) is not copied into the URI as the reference — the URI carries the opaque id only (4a91c2f7e8d3).

parseUri stays strict: it accepts path: and nothing else. Readers that may see either form call parsePayload.

What HTTPS costs (and why that does not put path: back on the sticker)

A printed HTTPS URI is fetched, cached, and logged. That is a privacy cost, and it is written here rather than discovered later:

SurfaceWhat leaks
GET on the hosted pageThe operator sees that this sticker was scanned, from which IP, at which time
Camera / OCRThe full URI sits in screenshots, gallery backups, and accessibility trees
CDN / edgeCache keys and access logs see the path and often the query
RefererA click-through from a wallet page can send the URI to a third party

This cost is accepted. It is the price of a code that opens when no PATH app is installed. It does not put path: back on a printed sticker — a custom scheme that nobody handles is a dead sticker, not a private one.

A host that is not listed on the issuer's discovery hosts[] is not that issuer. Several hosts for one operator are aliases, not several operators. There is no resolveIssuerBase: the HTTPS host is the routing index; issuerOrigin() returns it.

Kinds

Closed allowlist. None is ten characters — that reservation belongs to inherited one-segment payment links (/p/<code>), which are not PATH URIs.

KindMeaningObject belongs to
payPay this destinationADDRESS
requestA specific demand for paymentREQUEST
checkoutA merchant checkout sessionREQUEST
subscribeA recurring mandateCONNECT
connectA connection requestCONNECT
fconnectA financial connection requestCONNECT
mandateConsent to a delegationCONNECT
claimA code presented by the payerADDRESS
presentA request to present identityID
receiptA verifiable settlement receiptSETTLEMENT

The right-hand column is the rule, not an annotation:

INTEROP carries the envelope. The pillar owns the object.

Two rows repay attention. pay points at a destination while request carries a claim — they look alike in a QR code and behave differently everywhere else. And subscribe looks exactly like a payment link while creating a standing permission, which is why it is CONNECT; file it under REQUEST and you ship without revocation or counters.

Modes

A reader MUST determine the mode without a network call. presented is not a third grammar — it is the CPM rules on kind claim.

ModeWho displaysContentNetwork to readUsage
staticThe receiver, onceTerms are in the URINoPrinted sticker, invoice, profile
dynamicThe receiver, per transactionA short reference; the object lives at the issuerYesTill, order, PR, SR, CR, FCR
presentedThe payer (kind=claim)Bearer token, one use, short lifeYes (acquirer side)POS: the terminal scans the customer

Dynamic is unreadable offline. That is not a limitation to discover in the field: the URI carries a reference, not the terms. Without the issuer, a reader can recognise the kind and refuse politely. It cannot invent an amount.

claim is DRAFT as a product flow. Its rules are not:

  • one use, invalidated on first presentation;
  • 60–180 seconds, rotating on screen;
  • bound to the presenter — never reused for another amount or another acquirer;
  • never an amount inside the code (the acquirer proposes, the presenter confirms);
  • never left in a shareable screenshot.

The reading algorithm

Normative. Implementations MUST follow it in order.

  1. Trim leading and trailing whitespace. Nothing else.
  2. Dispatch the form. https: → parse the HTTPS grammar. path: → parse the alias. Anything else is not_path. An input that merely contains a PATH URI is rejected — never searched for a match. http: is not HTTPS.
  3. HTTPS path. Exactly two segments after /p/: /p/<kind>/<reference>.
    • One segment (/p/<code>) is not a PATH URI (not_path) — an inherited payment link.
    • Two segments, kind not in the allowlist → unknown_kind. Never treat an unknown kind as a code.
  4. Match the whole string. Partial matches are rejected. parseUri additionally requires the input to start with path: and nothing else.
  5. Resolve the kind. Unknown kinds fail with the kind named, so the error reads "upgrade your reader" rather than "malformed".
  6. Parse parameters as a query string. Unknown parameters are preserved, never dropped. op= on HTTPS is malformed. op= on path: is a routing hint, not a term.
  7. Verify the signature where one is present, before acting.
  8. Fetch state for dynamic kinds. The URI carries a reference; the current terms come from the issuer. Offline, a dynamic URI is unreadable.

Steps 2 and 3 are the security-relevant ones. A reader that scans for a pattern and takes the first match can be redirected by appending a crafted suffix to an innocuous string. Anchor at both ends. A single segment after /p/ is a different object — do not promote it to PATH by guessing a kind.

Signed URIs

Static codes that carry their own terms are signed. The signature is Ed25519, base64url (URI sig is not the hex used on JSON envelopes).

Fig. 02 — Signed static pay
Kind, reference and every parameter except sig and op are covered. Host is not.

Canonical form: kind + reference + every parameter except sig and op, keys sorted. Unknown parameters are covered. Host is not — routing is not identity, so the same object verifies as HTTPS and as a path: alias.

An attacker who edits the amount, or who drops a parameter they do not understand, breaks the signature.

Size is measured on the HTTPS form. A signed static pay SHOULD stay under 300 characters on https://<host>/p/pay/…. That prefix costs twenty to forty characters more than path:pay/, depending on the host. Above 300, error correction drops and the sticker stops working in exactly the conditions it was printed for.

What a QR SHOULD NOT carry

A printed code SHOULD NOT put a chain address in the clear. The reference is an opaque PATH id; resolution returns a capability, not a wallet. Publishing 0x… on a shop window correlates the merchant's history for anyone with a camera — the failure the directory was built to avoid.

op= is not the Goldrail qr_institution_slug. The slug stays where it is (compact QR, onboarding). PATH does not reuse it as a routing index. The host (HTTPS) or a declared hosts[] entry is the routing index; op= is only a hint on the path: alias.

For dynamic kinds the URI is still PATH — two segments — and it stays short because the terms live behind the reference:

Fig. 03 — Hosted PATH URI

The reference is random, not sequential. Fetching it returns the signed object. The domain says where to fetch; the signature says who is responsible. Verify the kid, then display the issuer's verified name rather than the URL.

A one-segment https://pay.example.com/p/7fk2m9pq3vx8 is an inherited payment link, not Fig. 03.

Mapping to existing standards

PATH does not replace what already works at the counter. Where a rail has a native format, implementations SHOULD map rather than compete. One table — informative, not a second copy elsewhere:

StandardMapping
EMVCo MPMkind=pay, merchant-presented, mode static / dynamic
EMVCo CPMkind=claim, consumer-presented, mode presented
EPC069-12kind=pay, rail sepa
BIP-21kind=pay, chain-native
EIP-681kind=pay, chain-native

A reader that understands PATH and one native format covers most of what it will meet in the field.


On this page