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:
| Question | When | Answered by |
|---|---|---|
| Is this a PATH code at all? | Instantly, offline, at the camera | The shape of the payload |
| Which network, which operator, which keys? | On the first fetch | The 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
https://pay.northstar.com/path/7fk2m9pq3vx8?n=openconnectThere 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=openconnectAnyone 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.
- Host. The link names it. This is the only thing the code asserts that cannot be forged without also controlling DNS and TLS.
- Discovery.
GET /.well-known/path-configurationon that host. The operator declares its network. - 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.
- Object.
GETthe reference. The response carries thetypeand the terms. - Signature. Verify the envelope against
/.well-known/path-keysfor the declaredkid.
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.
type | Meaning | Owned by |
|---|---|---|
path.address | A destination on standing terms | ADDRESS |
path.request | A specific demand for payment | REQUEST |
path.checkout | A demand plus a hand-back | REQUEST |
path.mandate | A standing permission | CONNECT |
path.claim | A code presented by the payer | ADDRESS |
path.receipt | A verifiable settlement outcome | SETTLEMENT |
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.
| Amount | Merchant hand-back | Lifecycle | |
|---|---|---|---|
path.address | The payer chooses | — | None |
path.request | Fixed at issue (or left open) | — | created → paid / expired / revoked |
path.checkout | Fixed at issue | success_url, cancel_url | Same, 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.
- Trim leading and trailing whitespace. Nothing else.
- Require
https:.http:is not HTTPS. Anything else isnot_path. - Match the whole string. A payload that merely contains a URL is rejected, never searched for a match.
- Check the marker. First path segment exactly
path, followed by exactly one segment: the reference. Otherwisenot_path. - Validate the reference against
[A-Za-z0-9_.-]+. Otherwisemalformed. - Parse the query. Keep unknown parameters. Treat
n=as a hint with no authority. - Run the chain of trust (discovery → register → object → signature).
- 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-capableThe 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.
| Surface | What leaks |
|---|---|
| GET on the hosted page | The operator sees this code was scanned, from which IP, at which time |
| Camera / OCR | The full URL sits in screenshots, gallery backups, accessibility trees |
| CDN / edge | Cache keys and access logs see the path and often the query |
| Referer | A 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_slugstays 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:
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.
| Standard | Mapping |
|---|---|
| EMVCo MPM | path.address or path.request, merchant-presented |
| EMVCo CPM | path.claim, consumer-presented |
| EPC069-12 | path.address, rail sepa |
| BIP-21 | path.address, chain-native |
| EIP-681 | path.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.1 | 0.2 |
|---|---|
path:<kind>/<ref> alias | Withdrawn. One HTTPS form |
https://host/p/<kind>/<ref> | https://host/path/<ref> |
| Kind in the URL, ten values | type in the object, six values |
op= routing hint | Withdrawn. Replaced by n= as a network hint with no authority |
sig= on the URI (base64url) | Withdrawn. The object is signed; envelopes keep hex |
parseUri / parsePayload | One parser |
One segment = not_path, two = PATH | Marker segment, then one reference |
| Network not addressed | Discovery + register, as a five-step chain |
| ~300-character budget | Not a constraint at this length |
Codes issued under 0.1.1 must be re-issued. Readers keep the anchored-parsing behaviour unchanged.
PATH CROSSWAY
The external-network resolution layer. Which outside registry knows a key, which participant holds it, where to send, and whether the name matches — without PATH becoming a directory of other networks' accounts.
Discovery
What an operator publishes about itself, why it is unauthenticated, and the fields a prospective member should read first.