GitHub

Docs

PATH INTEROP

One HTTPS link, a marker that identifies it offline, and a discovery document that says which network stands behind it.

DRAFT v0.2 — supersedes the v0.1.1 grammar. The path: scheme, the kind-in-the-URL (/p/<kind>/<ref>), op= and URI signatures are withdrawn. The SDK still ships the 0.1.1 behaviour; see what changed.

The problem this pillar solves

A code is scanned. Before anything else, a wallet has to answer two questions, and they are not the same question:

QuestionWhenAnswered by
Is this a PATH code at all?Instantly, offline, at the cameraThe shape of the payload
Which network, which operator, which keys?On the first fetchThe discovery document

A design that answers only the first is a format. A design that answers only the second is a private API. Interoperability needs both.

One form

Fig. 01 — Canonical form
https://<host>/path/<reference>?n=<network>
schemeauthoritymarkeropaque idhint
The only form. Printed, tapped, clicked, handed app-to-app — the same string.
https://pay.northstar.com/path/7fk2m9pq3vx8?n=openconnect

There is one form. A QR code, an NFC tag, a deeplink and an in-app handoff carry the same string. There is no second grammar for any of them.

No custom scheme. A path: URI fails silently when no app claims it, and fails worse when several do — the OS picks one, or shows a chooser, or does nothing. HTTPS always degrades: it opens a page. With Universal Links and App Links it opens the app when one is installed. Operators keep their own deeplinks (northstar://, wallet://) for their own products; PATH does not compete for that slot.

No kind in the URL. What the reference points at — a destination, a demand for payment, a mandate, a receipt — is a property of the object, not of the link. Putting it in the path duplicates it, and a duplicated fact is a fact that can disagree with itself.

The marker

The first path segment MUST be exactly path. That is the offline recognition rule, and the whole of it.

Every interoperable code format carries a marker: EMVCo has a GUID (br.gov.bcb.pix for PIX), BIP-21 has a scheme. Without one, a scanner cannot tell a payment code from a link to a blog post without making a network call to every host it sees.

/p/ was considered and rejected: it is a common prefix for product and post pages across the web, so it produces false positives in a general-purpose scanner.

The reference

Opaque, [A-Za-z0-9_.-]+. No colon. Random, never sequential.

The PATH ADDRESS spelling path:4a91c2f7e8d3 is not the reference. The link carries the bare id: 4a91c2f7e8d3. An address is a thing you resolve; a link is a thing you open.

What the query string may carry

Only n=<network-slug>, and only as a hint. Everything else is reserved.

Notably, a PATH link MUST NOT carry an amount, a currency, or a memo. Terms live in the signed object behind the reference. A number in a query string is editable with a text editor, and a wallet that renders it before the fetch resolves has shown the payer a figure the issuer never stated. Keeping money out of the URL removes that class of attack entirely, and keeps the code short enough to print well.

Unknown parameters are preserved, never dropped — a reader that strips what it does not understand breaks forward compatibility.

Which network is behind the code

This is the question the link alone cannot answer, and must not pretend to.

n= is a claim, not a fact

https://pay.northstar.com/path/7fk2m9?n=openconnect

Anyone can print a sticker that says n=openconnect. A wallet MAY use the hint to pick a cached rulebook or render a waiting state. A wallet MUST NOT use it to decide anything: not trust, not display of a network name, not routing.

Discovery is the authority

The link contributes exactly one hard fact: the host. Everything else is fetched from it.

GET https://pay.northstar.com/.well-known/path-configuration
{
  "operator": { "slug": "northstar", "domain": "pay.northstar.com", "jurisdiction": "US" },
  "network":  { "slug": "openconnect",
                "legal_name": "Open Connect",
                "rulebook": { "url": "https://…", "version": "1.2" } },
  "conformance": ["PATH-CORE.Discovery", "PATH-REQ.Link", "PATH-INTEROP.Read"],
  "endpoints": { "resolver": "…", "requests": "…", "keys": "…" }
}

Unauthenticated, on purpose. Anyone considering an integration has to read it before there is a relationship to authenticate.

The chain of trust

Five steps. A reader MUST complete them before presenting a payment screen.

  1. Host. The link names it. This is the only thing the code asserts that cannot be forged without also controlling DNS and TLS.
  2. Discovery. GET /.well-known/path-configuration on that host. The operator declares its network.
  3. Register. That network's registry lists its members. Is this operator in it, with this domain? A host that claims a network which does not list it is not in that network.
  4. Object. GET the reference. The response carries the type and the terms.
  5. Signature. Verify the envelope against /.well-known/path-keys for the declared kid.

A forged sticker on an unknown domain fails at step 3. A tampered object fails at step 5. The code itself proves nothing — it does not have to. It designates a host, and the host is accountable to a network.

Step 3 is the one implementations skip. Without it, n= and the discovery document are both just self-assertions, and any domain can call itself a member of any network.

The object behind the reference

GET https://pay.northstar.com/path/7fk2m9pq3vx8
Accept: application/json
{
  "type": "path.request",
  "reference": "7fk2m9pq3vx8",
  "issuer": "northstar",
  "amount": "5000",
  "currency": "USD",
  "status": "created",
  "expires_at": "2026-09-13T18:00:00Z",
  "kid": "northstar_2026_01",
  "signature": "…"
}

The same URL serves a human page to a browser and the object to a client that asks for JSON. One address, two representations — no second endpoint to keep in sync.

type replaces the old URL kind. Closed allowlist; the pillar that owns the object is the rule, not an annotation.

typeMeaningOwned by
path.addressA destination on standing termsADDRESS
path.requestA specific demand for paymentREQUEST
path.checkoutA demand plus a hand-backREQUEST
path.mandateA standing permissionCONNECT
path.claimA code presented by the payerADDRESS
path.receiptA verifiable settlement outcomeSETTLEMENT

INTEROP carries the envelope. The pillar owns the object.

path.mandate is the one to notice: it arrives through the same link as a payment and creates a standing permission. Handle it as a request and you ship without revocation and without counters.

An unknown type is an upgrade prompt, never a guess. This code is newer than this app is actionable; invalid code is not.

The three a payer meets

Three of those six cover almost every scanned code. They form a ladder — each rung fixes one more thing in advance.

AmountMerchant hand-backLifecycle
path.addressThe payer chooses—None
path.requestFixed at issue (or left open)—created → paid / expired / revoked
path.checkoutFixed at issuesuccess_url, cancel_urlSame, plus payer_member

The destination is present in all three. It is the object in the first, and the accepts field in the other two — the way an IBAN is the subject of a bank card and a line printed on an invoice. path.address is not "the one with an address in it".

path.address — live. A permanent policy carried by a standing intent, versioned rather than overwritten. The answer is what I accept right now. Two scans of the same sticker a month apart may differ because the receiver added a rail — nothing happened, the policy moved. No status, no expires_at: there is no event to track. A wallet shows enter an amount.

{
  "type": "path.address",
  "reference": "4a91c2f7e8d3",
  "issuer": "northstar",
  "grammar": 2,
  "accepts": [
    { "rail": "book", "asset_type": "fiat", "asset_code": "USD" },
    {
      "rail": "blockchain",
      "chain": { "slug": "base" },
      "asset_type": "token",
      "asset_code": "USDC",
      "standard": "erc20",
      "contract": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
    }
  ],
  "kid": "northstar_2026_01",
  "signature": "…"
}

path.request — frozen. Terms are copied onto the demand at issue and signed there, not resolved from the address at read time. The payer sees exactly what the issuer committed to. If the receiver changes rails tomorrow, yesterday's demand is unchanged — it is a debt, not a policy. A wallet shows confirm 5 000 USD.

An absent amount is a valid demand, not an address: a tip jar or an open invoice still expires and can still be revoked.

A request that is no longer payable answers 410, with REQUEST_EXPIRED or REQUEST_REVOKED. That is the correct answer, not a failure — this demand has expired is what the payer must read, never a network error.

{
  "type": "path.request",
  "reference": "7fk2m9pq3vx8",
  "issuer": "northstar",
  "amount": "5000",
  "currency": "USD",
  "accepts": [ "…" ],
  "fees": { "…": "…" },
  "order_reference": "CMD-8841",
  "status": "created",
  "expires_at": "2026-09-13T18:00:00Z",
  "kid": "northstar_2026_01",
  "signature": "…"
}

path.checkout — frozen, plus the return. A demand plus the only two things a merchant flow needs that a bare link does not: where to send the payer back, and where to notify the merchant. The webhook is not in the public object — that is between the merchant and its provider, and the payer has no business seeing it. Only the visible return travels.

That hand-back is the sole part of checkout PATH standardises, because it is the only part that crosses between the merchant's provider and the payer's wallet. Basket, tax, shipping, stock and promotions belong to whoever built the store.

payer_member is where this stops being theoretical: the wallet that completes a session may belong to another member entirely, and the merchant integrates nothing to accept it.

{
  "type": "path.checkout",
  "reference": "9qz4m2vx7k",
  "issuer": "northstar",
  "amount": "42000",
  "currency": "USD",
  "status": "open",
  "expires_at": "2026-09-13T14:30:00Z",
  "return": {
    "success_url": "https://boutique.sn/merci",
    "cancel_url": "https://boutique.sn/panier"
  },
  "kid": "northstar_2026_01",
  "signature": "…"
}

One reading path

The payer scans without knowing which of the three it is. That is the point of putting the type in the object.

switch (obj.type) {
  case 'path.address':  return { screen: 'enter-amount', accepts: obj.accepts };
  case 'path.request':
  case 'path.checkout': return { screen: 'confirm', obj };
  default:              return { screen: 'update-required' };
}

request and checkout land on the same confirmation screen; checkout only adds a redirect at the end. A wallet that can pay one can pay the other without a line of new code.

This requires one reference namespace. A checkout session needs a public reference from the same generator as a request, and GET /path/<reference> has to serve all three types. A session readable only at its own endpoint is not reachable by a code.

The reading algorithm

Normative. Implementations MUST follow it in order.

  1. Trim leading and trailing whitespace. Nothing else.
  2. Require https:. http: is not HTTPS. Anything else is not_path.
  3. Match the whole string. A payload that merely contains a URL is rejected, never searched for a match.
  4. Check the marker. First path segment exactly path, followed by exactly one segment: the reference. Otherwise not_path.
  5. Validate the reference against [A-Za-z0-9_.-]+. Otherwise malformed.
  6. Parse the query. Keep unknown parameters. Treat n= as a hint with no authority.
  7. Run the chain of trust (discovery → register → object → signature).
  8. Dispatch on type. Unknown type → upgrade prompt.

not_path is the common case in a general-purpose scanner. It means this was something else — fall through to EMVCo, BIP-21, or the operator's own link format. It is not an error to show a user.

Steps 3 and 4 are the security-relevant ones. Match the complete string, at both ends. A substring match is not enough.

Hosts that are not shaped like this

An operator with millions of printed stickers is not going to reprint them, and PATH does not require it.

A wallet MAY probe any scanned HTTPS URL for /.well-known/path-configuration on its origin. If the document is served, the host speaks PATH, and the scanned URL can be fetched as an object even though it does not carry the /path/ marker.

https://wallet.example/pay/xyz          ← not the marker
https://wallet.example/.well-known/path-configuration   ← served ⇒ PATH-capable

The marker is the fast path, not an admission requirement. This is what makes the protocol adoptable by operators who already have a link format and no reason to abandon it.

A single-segment legacy link on a PATH host is treated the same way: fetch it, read the type.

What HTTPS costs

A printed HTTPS link is fetched, cached, and logged. That cost is accepted and written here rather than discovered later.

SurfaceWhat leaks
GET on the hosted pageThe operator sees this code was scanned, from which IP, at which time
Camera / OCRThe full URL sits in screenshots, gallery backups, 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 URL to a third party

This is the price of a code that opens when no PATH app is installed. It does not bring a custom scheme back onto a sticker: a scheme nobody handles is a dead sticker, not a private one.

What a code SHOULD NOT carry

  • A chain address in the clear. Publishing 0x… on a shop window correlates the merchant's entire history for anyone with a camera. The reference is opaque; resolution returns a capability, not a wallet.
  • An amount. See above.
  • An institution slug as routing. The Goldrail qr_institution_slug stays where it is. The host is the routing index. n= is a hint about a network, not a redirect to an operator.

Size

With no terms and no signature in the URL, a PATH link is short by construction:

Fig. 02 — A complete PATH code

Thirty-nine characters. A QR at that length 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 — the conditions it exists for. The old ~300-character budget was a constraint invented by putting signed terms in the URL; removing them removes the constraint.

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.

StandardMapping
EMVCo MPMpath.address or path.request, merchant-presented
EMVCo CPMpath.claim, consumer-presented
EPC069-12path.address, rail sepa
BIP-21path.address, chain-native
EIP-681path.address, chain-native

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

What changed from 0.1.1

0.1.10.2
path:<kind>/<ref> aliasWithdrawn. One HTTPS form
https://host/p/<kind>/<ref>https://host/path/<ref>
Kind in the URL, ten valuestype in the object, six values
op= routing hintWithdrawn. Replaced by n= as a network hint with no authority
sig= on the URI (base64url)Withdrawn. The object is signed; envelopes keep hex
parseUri / parsePayloadOne parser
One segment = not_path, two = PATHMarker segment, then one reference
Network not addressedDiscovery + register, as a five-step chain
~300-character budgetNot a constraint at this length

Codes issued under 0.1.1 must be re-issued. Readers keep the anchored-parsing behaviour unchanged.


On this page