Verifiable receipts (seller)
Introduction
Section titled “Introduction”A PipRail gate already proves every payment by re-deriving each field from the settlement transaction your own RPC confirmed (that’s verification). A verifiable receipt packages that proof so it travels: the buyer keeps a small, self-contained record, and anyone — the buyer, an auditor, a reputation system, a court — can re-run the same on-chain verification later with only an RPC. No key, no backend, no PipRail account, nothing hosted in between.
That makes the receipt fundamentally different from what
Receipts & onPaid covers. That page is the delivery /
reconciliation side — onPaid fires inside your server so you can fulfil the order and persist
the spend. This page is the verifiability side — what the buyer takes away, re-checkable by a
third party who never trusts you. The two are independent: you can run onPaid without receipts, or
receipts without onPaid, or both.
You opt in with one option on
requirePayment / createPaymentGate:
import { createPaymentGate } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…', receipts: true, // emit a chain-grounded receipt on every settled payment})The two tiers
Section titled “The two tiers”A receipt has two independent proof layers. Tier 1 is automatic the moment receipts are on; Tier 2 is an extra opt-in signature.
| Tier 1 — chain-grounded (the default when receipts are on) | Tier 2 — service attestation (opt-in, EVM-only) | |
|---|---|---|
| Authority | the settlement tx the chain already signed | the merchant’s signature over the official EIP-712 RECEIPT_TYPES |
| Proves | funds provably moved to payTo for at least amount | the resource was served — the one thing the chain can’t attest |
| Key needed | none | the merchant’s existing payTo wallet (no new key) |
| New infra | none — reuses the driver’s verify() | none — signs with the key you already have |
| Verified by | PipRailClient.verifyReceipt | PipRailClient.verifyAttestation |
| Wire slot | extensions['offer-receipt'].info.settlement (PipRail) | extensions['offer-receipt'].info.receipt (the spec slot) |
Tier 1 — chain-grounded (no key)
Section titled “Tier 1 — chain-grounded (no key)”receipts: true stamps the verified X402Receipt
plus the reconstruction metadata a third party needs into the offer-receipt block: the resource
URL, the asset decimals, and — for the five memo/nonce-bound families — the challenge
nonce. A verifier re-reads the settlement tx through
the chain’s own driver and ignores every claim in the receipt — so the receipt can’t lie about
who was paid, in what asset, or how much. There is no key, no signature, and nothing to compromise:
the chain is the authority. This is the minimum, and it’s on for every settled payment when
receipts is set.
Tier 2 — service-delivery attestation (EVM-only)
Section titled “Tier 2 — service-delivery attestation (EVM-only)”The chain can prove the money moved. It cannot prove you actually served the resource the buyer
paid for. For that single gap, sign the official x402 offer-receipt EIP-712 receipt with your
existing payTo wallet — a verifier then checks recover(signature) === payTo, so the
“I delivered” claim collapses to an on-chain fact (the same key that received the money signed the
attestation). Zero new infrastructure.
createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…', receipts: { attest: { wallet: { key: process.env.PAYTO_KEY } } },})The wallet is the same { key } form every PipRail wallet takes — the merchant’s payTo
private key. PipRail signs the FROZEN official domain { name: 'x402 receipt', version: '1', chainId: 1 } (chainId 1 for every network — even non-EVM payment chains — so signing is uniform)
and the load-bearing RECEIPT_TYPES field order
(version, network, resourceUrl, payer, issuedAt, transaction), byte-matched against
@x402/extensions. The signed message follows the §5.3 empty-string rule. Verify it with
PipRailClient.verifyAttestation.
The wire shape
Section titled “The wire shape”When receipts are on, the gate adds an extensions['offer-receipt'].info block to the
SettlementResponse on the PAYMENT-RESPONSE header. Field placement is spec-faithful so a stock
reader and a PipRail reader both work:
{ "scheme": "onchain-proof", "success": true, "network": "eip155:8453", "transaction": "0x9f…", // the settled on-chain tx (empty "" when includeTxHash:false) "asset": "0x833589fC…", "amount": "50000", "payer": "0x857b…", "payTo": "0xMerchant…", "verifiedAt": "2026-06-20T…Z", "nonce": "…", // the challenge nonce (Template-A re-verify); present when receipts on "extensions": { "offer-receipt": { "info": { "settlement": { /* PipRail's X402Receipt — the chain-grounded record (Tier 1) */ }, "resource": { "url": "https://api.example.com/report" }, "decimals": 6, "receipt": { /* the official SignedReceipt — present ONLY for a Tier-2 attestation */ } } } }}info.settlementholds PipRail’s chain-groundedX402Receipt— a PipRail-namespaced sibling a stock@x402/extensionsreader ignores, and the recordverifyReceiptre-reads.info.receiptis the spec slot: the officialSignedReceipt({ format, payload, signature, signer }). It is present only when a Tier-2 attestation was signed, so a stock reader (extractReceiptFromResponse→info.receipt) reads the signed receipt byte-compatibly. When there’s no attestation (Tier-1 only),info.receiptis absent — a stock reader correctly sees “no signed receipt”, because there isn’t one.
A PipRail client reconstructs the full self-contained
PipRailReceipt — { piprail, receipt, resource, decimals, attestation? } — from this block in one pure read (no chain call). With receipts off, none of
this is emitted and both the 402 and the 200 are byte-identical to before.
The ReceiptOption interface
Section titled “The ReceiptOption interface”receipts accepts true/{} (Tier-1 with defaults) or a ReceiptOption object:
interface ReceiptOption { /** Put the settlement tx hash in the receipt. **Default true** — see the divergence below. */ includeTxHash?: boolean /** Canonical resource URL embedded in the receipt (what was paid for). Default '' — the buyer's * client fills it from the URL it fetched; set it here to ground the raw header a third party * reads directly off the wire. */ resource?: string /** Tier-2: also sign an EIP-712 delivery attestation with the merchant's payTo wallet (EVM). * `{ wallet: { key } }` → the merchant's existing payTo key. * `{ jws }` → a reserved managed signer (R3) — NOT yet implemented; degrades to Tier-1. */ attest?: { wallet: unknown } | { jws: unknown }}includeTxHash — a deliberate divergence from the reference default
Section titled “includeTxHash — a deliberate divergence from the reference default”PipRail defaults includeTxHash to true — the settlement tx hash is in the receipt, so it’s
third-party verifiable on-chain out of the box. This inverts the x402 reference implementation’s
privacy-minimal default (which omits the tx, per §5.2/§11 of the spec). The envelope stays
byte-compatible; the default is PipRail’s, because the whole pitch is the open, self-verifiable
chain. Market it as “byte-compatible envelope, PipRail-default verifiability,” never “drop-in
identical defaults.”
The nonce field — memo/nonce-bound families
Section titled “The nonce field — memo/nonce-bound families”Five families bind a payment to the challenge by carrying the nonce in an on-chain memo / note /
comment rather than in a tx digest: Stellar, XRPL, NEAR, Algorand, TON (x402 “Template A”). To
re-verify one of these off-chain, the verifier has to re-scan the merchant’s account for that exact
nonce — so the receipt must carry it. When receipts is on, the gate stamps the challenge nonce
(the value it minted into the 402 and the buyer echoed back) onto X402Receipt.nonce automatically.
The other families (EVM, Solana, Tron, Sui, Aptos, and every native coin — “Template B”) are
digest-bound: they verify on the transaction alone, so for them the nonce is purely
informational. You don’t configure any of this — it’s automatic — but it’s why a receipt from a
Template-A chain that lost its nonce can’t be re-verified, while a digest-bound one can.
verifiableReceipts in the self-describe block
Section titled “verifiableReceipts in the self-describe block”When receipts are on and self-describe is on (it is by
default), the gate stamps a verifiableReceipts: true flag into the extensions.piprail block on the
402 challenge. So a crawler or agent reading just the 402 — before paying anything — knows the
200 will carry a self-verifiable receipt. It’s one boolean; cross-reference
self-describing endpoints for the full block.
Worked examples
Section titled “Worked examples”A basic Tier-1 gate
Section titled “A basic Tier-1 gate”The minimum. No key, third-party on-chain verifiable out of the box:
import { createPaymentGate } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…', receipts: true,})// Every settled payment now ships an X402Receipt at info.settlement,// carrying the tx hash, decimals, resource URL, and (where relevant) the nonce.A Tier-2 attested gate
Section titled “A Tier-2 attested gate”Adds the EIP-712 service-delivery signature with your existing receive key:
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…', receipts: { resource: 'https://api.example.com/report', // ground the raw header a third party reads attest: { wallet: { key: process.env.PAYTO_KEY } }, // the SAME { key } every PipRail wallet takes },})// Now info.receipt also carries the official SignedReceipt — recover(sig) === payTo proves you served it.A privacy-mode gate (no tx hash, attestation for proof)
Section titled “A privacy-mode gate (no tx hash, attestation for proof)”For a merchant who doesn’t want to bundle the payer→tx linkage but still wants a provable receipt:
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.05', payTo: '0xMerchant…', receipts: { includeTxHash: false, // wire `transaction` becomes '' (§5.3) attest: { wallet: { key: process.env.PAYTO_KEY } }, // delivery is still provable via the signature },})// Tier-1 is no longer on-chain re-readable (no tx), but verifyAttestation still proves you served it.See also
Section titled “See also”- Verifying receipts (buyer / anyone) — capture with
lastReceipt()and re-verify withverifyReceipt/verifyAttestation. - Receipts & onPaid — the delivery side: persist a settled receipt to your own store or webhook. That’s reconciliation; this page is verifiability.
- Self-describing endpoints — the
verifiableReceiptsflag on the 402. - Verifying payments — the in-process verification a receipt re-packages.