Open payment request protocol
A signed request format, published for any wallet.
A ProxyPay register issues a payment request. It is a signed, versioned, size-limited message, and any wallet — not only ones built by companies affiliated with ProxyPay — can verify it without asking ProxyPay for permission or calling a ProxyPay private service. This page is that format, published: the specification, the JSON schema, and the fixed conformance fixtures a wallet implementer tests against, each with its exact hash.
What a request is
A payment request is UTF-8 JSON whose exact bytes equal RFC 8785 JSON Canonicalization Scheme (JCS) output — canonical, deterministic bytes, not merely valid JSON. It is signed with Ed25519 over the unsigned claims. The wire form is bounded on purpose: 4,096 bytes, four levels of nesting, a fixed top-level field count.
Money is always a positive canonical decimal integer string in the token's base units — no floats, no signs, no leading zeros. A wallet verifies the exact network, token mint and decimal count from its own trusted configuration; it never infers them from the request.
- Envelope fields
- version, issuer, key_id, request_id, payment_reference, merchant and location display names, recipient, network, token mint, amount in base units, issued-at and expires-at instants, nonce, display reference, and the signature — sixteen fields, no more and no fewer.
- Signature
- Ed25519, canonical unpadded base64url, over the UTF-8 JCS bytes of the other fifteen claims. The key ID is itself one of the signed claims.
- What is prohibited
- No customer, membership, receipt, item or loyalty data is a permitted field. Fields carrying it are rejected outright, not merely discouraged, because the request is a payment instruction, not a record of who is buying what.
How a wallet verifies a request
Validation runs in one fixed order, and the first failing stage decides the outcome — a wallet does not keep checking after the first failure, and two conforming implementations cannot disagree about which stage a bad request fails at.
Local policy first
Before reading the request at all, the wallet checks its own configuration is sane: the current time, its issuer allowlist, the expected network and token mint, and its own ceilings for amount size, validity duration, clock skew and metadata cache age. A caller may tighten these ceilings; it may never loosen them.
Wire and shape
The raw bytes must be at most 4,096 bytes of strict UTF-8, nested at most four levels deep, with no duplicate top-level JSON name — checked before the bytes are even parsed as JSON.
Parse and canonicalize
The bytes parse as one JSON object, and re-canonicalizing that object must reproduce the exact input bytes. Outer whitespace is therefore a rejection, not something a scanner or a deep link may trim.
Field shape
Unknown fields are rejected, then missing fields, then fields of the wrong type. Only then are the sixteen permitted fields validated by their individual rules — issuer format, ID patterns, timestamp format, amount digits, and so on.
Claim semantics
Version is supported, issuer matches an exact allowlisted HTTPS identity, network and token mint match the wallet's own configuration, the amount is a valid positive integer within the digit ceiling, and the validity window is well-formed, not expired, not yet future-dated beyond the allowed skew, and not longer than the ceiling.
Issuer metadata and key
The wallet validates the issuer's published key set against its own bounded shape, requires an exact issuer match and a fresh cache, finds exactly one key matching the request's key_id, and refuses a revoked key immediately even if the signature below would otherwise verify.
Signature
The Ed25519 signature is verified over the canonical bytes of the other fifteen claims, using the exact public key published for that key_id.
Replay reservation
request_id, payment_reference and nonce are reserved atomically, all three, for the life of the request. A duplicate of any one of them fails closed, and an otherwise-invalid request must never consume a reservation.
Display and approval
Only after every prior stage passes does the wallet display the critical fields to the customer and require a separate, explicit approval. Nothing prior to this step can approve or submit a transfer by itself.
How a wallet finds the issuer's keys
Every request names its issuer as an exact HTTPS identity. That issuer publishes its current signing keys at a public GET /v1/issuer-metadata endpoint on its own origin, and a wallet fetches that document to learn which keys are currently valid.
The endpoint is unauthenticated by design: a public key is public, and the only party who needs this document — a wallet verifying a signature it was just handed — holds no operator credential for the issuer's systems. The response is cache-control public, max-age=300, and the document's own expires_at is always exactly 300 seconds after its fetched_at — the protocol caps a client's metadata cache at 300 seconds and a caller may tighten that bound but never loosen it.
An issuer that cannot currently produce a key set answers a distinct unavailable response rather than an empty key list, so a wallet can tell "try again shortly" from "no key matches this request" — those are different failures with different remedies.
- Each published key carries a key_id, its type and curve (Ed25519 in an OKP JWK), the raw public key bytes, an active-or-revoked status, and the time window the issuer claims for it.
- A revoked key is rejected immediately, even when its cryptographic signature would otherwise verify.
- Rotation publishes a new key before it is ever used to sign, and the previous key stays published and verifiable through its own overlap window rather than disappearing the moment a new key takes over.
- The metadata document defines no signing endpoint, no callback and no private route — it is read-only, and reading it can never move money or approve a transfer.
Versioning
This page publishes version 1 of the protocol. The version is a field in every envelope, not an assumption: a wallet that does not implement the version named in a request rejects it rather than guessing at a best-effort interpretation. Within v1, a verifier may tighten its own ceilings — a shorter validity window, a smaller amount limit, a shorter metadata cache — but may never loosen any ceiling this specification sets. A future version, if published, would be a new document at a new path, verified independently; it does not silently change what a v1 verifier accepts.
Downloads and hashes
The schema and fixtures are byte-identical to the reviewed source; the specification text is editorially adapted for publication. Verify the hash of anything you download before treating it as conformance material.
| File | Size | SHA-256 |
|---|---|---|
| v1/issuer-metadata-schema.json | 3,264 bytes | 826b8ea33e7e138f8353447834519f75dd025cafacbc307110845e33d9b07808 |
| v1/schema.json | 2,806 bytes | e69c1a3b5c4c6498246b5d34e00e079f58b963f6e207049ba97ce03ec3848e23 |
| v1/SPECIFICATION.md | 8,753 bytes | 610a8e00246079140617b305963ec9987fe6cdc859667068822f926fa8ffdbe6 |
| File | Size | SHA-256 |
|---|---|---|
| v1/fixtures/envelopes/duplicate-name.json | 767 bytes | c1e5a4f6a0df026f3cf435dc20bf1e514d88eca0576dddcd1e1b8592d6412b5a |
| v1/fixtures/envelopes/duplicate-nonce.json | 753 bytes | 29ed1caeca1bd664470a8179570ad440b068ac61d6316b091317c755e53348c6 |
| v1/fixtures/envelopes/duplicate-reference.json | 753 bytes | e8182e551018d767f3262fa4e09e5c2719c49f5f961817a02d4f6459e83dd021 |
| v1/fixtures/envelopes/expired.json | 753 bytes | d165e69911228b3f8d925765ce0acf381af37edb77a3fe4c154f9f300b8786a7 |
| v1/fixtures/envelopes/future-issued.json | 753 bytes | fe75d5bbeb69fa1f9cfcbca3d1104e648c3dc6d4e083f663994fe726ae39c997 |
| v1/fixtures/envelopes/invalid-signature.json | 753 bytes | 0ac3e864a451e7439912e9a64afd07a0e94189281f4de3121a21f2838e9ee6bc |
| v1/fixtures/envelopes/malformed-address.json | 719 bytes | db32b0ea46bf0c13252c6a857382b9211b66f17e54c80e13360a1c72c903dffa |
| v1/fixtures/envelopes/malformed-canonicalization.json | 818 bytes | d2fd52cb0d990d2bab8985f02812a728727c862245ba759e9e4f24a145a067e6 |
| v1/fixtures/envelopes/malformed-integer.json | 747 bytes | 1109844a2b9f77bbc416f1902c12d811a6a0d6d532673c084bca3cd585ac53a5 |
| v1/fixtures/envelopes/malformed-unicode.json | 751 bytes | 3ca2abe6d73620490bde0382a13ea657995c5bb4690e8fbe7c0898f97eb68a5e |
| v1/fixtures/envelopes/missing-signature.json | 652 bytes | a7e1cbbf7ffa25ce46ddb5717f13d66ab6bf036d187e74e94242de96b9872f3b |
| v1/fixtures/envelopes/overflow.json | 761 bytes | 83ce3bda233743bd0452e2dcdcb2eabd1d1ffc84baec74342bf8fa4a1f231c28 |
| v1/fixtures/envelopes/prohibited-field.json | 787 bytes | 6d64e8f06de0e808ab031618734356f3c231d3c634423b8915b9fbeb0213c296 |
| v1/fixtures/envelopes/replay.json | 753 bytes | 570952b7793e6a110530e161536fa8520a793acf0303e52de039f43d12e8094b |
| v1/fixtures/envelopes/revoked-key.json | 753 bytes | 570952b7793e6a110530e161536fa8520a793acf0303e52de039f43d12e8094b |
| v1/fixtures/envelopes/tampered-amount.json | 753 bytes | 41bb6d8e85ea65eb4da5bdc0d4e257ecd8dfc25780a4d01e08dd43b069e2fed6 |
| v1/fixtures/envelopes/tampered-recipient.json | 753 bytes | 4e875e1f3d7ad05c53a86eca433e94ee32c0d7e9aac127d07ba12b009174bba5 |
| v1/fixtures/envelopes/unknown-field.json | 769 bytes | 536f80e4f8c2a280945f11d184131e25241f183859915568622e98649b7a46f7 |
| v1/fixtures/envelopes/unknown-issuer.json | 745 bytes | c25b5472d96a963ff17d56f17b6183c0b5f0e400a0709f092ed6bc9a43ce78cc |
| v1/fixtures/envelopes/valid.json | 753 bytes | 570952b7793e6a110530e161536fa8520a793acf0303e52de039f43d12e8094b |
| v1/fixtures/envelopes/wrong-mint.json | 741 bytes | 911b4383e0867a40f26f46ca7bf680326f729ea3d3d2d77008b686366f3d7fde |
| v1/fixtures/envelopes/wrong-network.json | 754 bytes | 37b1dd7678688312d020c2c94d278144de1bd942c6a76d2efcfacc94c0529a5f |
| v1/fixtures/issuer-metadata/active.json | 349 bytes | 4cb4dc9a90c147c241dc66506eba66585118b7d6ec86a4f8eba0d51c93338917 |
| v1/fixtures/issuer-metadata/revoked.json | 386 bytes | ea205ea07e7814fb3ee344d35b1816ad86e101d3e7acfe7705e821dba02fcc9b |
| v1/fixtures/issuer-metadata/unknown-issuer.json | 341 bytes | e964c7c4c589a48208ac93313f4df2612f60e864190da89b37ca2ed5487307b5 |
What these fixtures are, and are not
These are synthetic, no-money conformance vectors: deterministic bytes used to test that a verifier accepts a valid request and rejects each specific way one can be invalid. They do not touch a real network, a real issuer or real funds, and none of the published files contain private key material — the fixture-generating seed is excluded from this publication.
Using the mark
A wallet or a merchant integration that implements this specification and passes the published conformance fixtures may describe itself with the plain sentence below. It is a factual statement about protocol support, not a certification ProxyPay issues or a logo ProxyPay licenses.
Accepts open payment requests (v1)This page does not claim that any wallet other than the one named in this site's footer disclosure implements this protocol. Any wallet may; implementing it does not require contacting ProxyPay, importing ProxyPay code, or paying ProxyPay anything.