Verifying receipts (anyone)
Introduction
Section titled “Introduction”A verifiable receipt is a small, self-contained
PipRailReceipt the buyer keeps after a paid request. Its defining property: anyone can
re-verify it against the chain with only an RPC — no key, no backend, no PipRail account, and no
trust in whoever handed it over. verifyReceipt re-reads the settlement transaction and re-derives
the recipient, asset, and payer itself; the receipt’s own claims are never believed.
This page is the read side of the seller’s emit side: capture the receipt, re-verify its Tier-1 chain grounding, and (optionally) check its Tier-2 delivery attestation.
Capture — client.lastReceipt()
Section titled “Capture — client.lastReceipt()”After a paid fetch, the buyer’s client holds the receipt the gate emitted:
import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'base', wallet: { key: process.env.AGENT_KEY! } })
const res = await client.fetch('https://api.example.com/report')const receipt = client.lastReceipt() // PipRailReceipt | null// `null` when the last settled fetch carried no receipt (the gate's `receipts` option was off),// or no payment has settled yet. Keep it — it's a portable, third-party-verifiable proof of purchase.lastReceipt() is pure — no chain read. The client captures the receipt from the
PAYMENT-RESPONSE header and stamps resource.url with the URL it actually fetched (authoritative
over the gate’s default ''). The shape is the self-contained bundle:
interface PipRailReceipt { piprail: '1' // receipt-format version (distinct from x402Version) receipt: X402Receipt // the verified settlement (carries the challenge `nonce`) resource: { url: string } // what was paid for decimals?: number // the asset's on-chain decimals (Stellar/XRPL/TON re-scale by it) attestation?: SignedReceipt // the OPTIONAL Tier-2 attestation — present only if the merchant signed}Everything a third party needs to rebuild the trusted accept and re-run the driver’s verify() is in
that one object — which is the whole point: it travels.
Re-verify Tier-1 — PipRailClient.verifyReceipt()
Section titled “Re-verify Tier-1 — PipRailClient.verifyReceipt()”The anyone-can-run primitive. Static (called on the class, not an instance) and wallet-free — a third party verifies with only a chain + RPC, no PipRail account, no key:
const v = await PipRailClient.verifyReceipt(receipt) // default RPC for the receipt's chain// or, with a custom endpoint:const v2 = await PipRailClient.verifyReceipt(receipt, { rpcUrl: 'https://my-base-rpc…' })It re-reads receipt.receipt.transaction through the receipt’s own network driver (resolved from
the CAIP-2 network, auto-mounted) and re-derives the on-chain fields, ignoring the receipt’s
claims. Internally it rebuilds a synthetic trusted accept from the receipt and runs the exact
same verify() the gate ran — so the chain, not the receipt, is the authority. The return is a
ReceiptVerification:
interface ReceiptVerification { ok: boolean // the chain confirms the settlement (≥ amount of asset moved to payTo) onChain: { payTo: string; asset: string; amount: string; payer: string } // re-derived from the tx matchesClaims: boolean // does the re-derived on-chain payer equal the receipt's claimed payer? ageSeconds: number // informational age since verifiedAt — NOT a validity gate error?: VerifyErrorCode // the closed code when ok is false}What each field means:
ok— the chain’s confirmation that the settlement is real: at leastamountofassetgenuinely moved topayTo. A forgedpayTo/asset, or anamountclaimed above what actually moved, makes the driver’sverify()fail →ok: false.onChain.payer— the one field genuinely re-derived from the tx (the rest are pinned by the synthetic accept the chain validated against). A forged claimedpayersurfaces asmatchesClaims: falseeven whileokstaystrue— the payment is real, but it didn’t come from who the receipt claims.onChain.amount— a verified lower bound, not a re-derived exact figure. Drivers threshold-checkpaid >= required, then echo the accept amount, sook: truemeans at least this much moved.ageSeconds— informational only. It is never a validity gate (verifyReceipt uses a ~100-year synthetic window precisely so a driver’spayment_expiredbranch can’t fire on a legitimately old receipt).error— a closedVerifyErrorCode(e.g.tx_not_found,transfer_not_found,amount_too_low) whenokis false.
verifyReceipt never throws. An RPC error, an unknown network, an unmounted driver, or a
malformed/foreign receipt all return a structured { ok: false, error } — so a verifier can call it
on untrusted input without a try/catch.
Catching a forgery
Section titled “Catching a forgery”ok and matchesClaims separate two different lies, and you usually want both:
const v = await PipRailClient.verifyReceipt(receipt)
if (!v.ok) { // The chain does NOT confirm this settlement — a fabricated tx, a forged payTo/asset, // or an over-stated amount. Reject it. console.warn(`receipt does not verify on-chain: ${v.error}`)} else if (!v.matchesClaims) { // A REAL payment moved (ok), but its on-chain payer is NOT the one the receipt claims — // e.g. someone re-labelled another buyer's settlement as their own. console.warn(`payment is real, but payer is ${v.onChain.payer}, not the claimed payer`)} else { console.log(`verified: ${v.onChain.amount} of ${v.onChain.asset} → ${v.onChain.payTo} from ${v.onChain.payer}`)}A forged payer keeps ok: true (the payment is genuine) but flips matchesClaims to false. A
forged payTo, asset, or inflated amount fails the on-chain verify() outright → ok: false.
Either way, nothing throws.
Re-verify Tier-2 — PipRailClient.verifyAttestation()
Section titled “Re-verify Tier-2 — PipRailClient.verifyAttestation()”If the merchant signed a Tier-2 service-delivery attestation (EVM), check it separately — it proves the one thing the chain can’t: that the resource was served.
const a = await PipRailClient.verifyAttestation(receipt)// { ok: boolean; signer?: string; reason?: string }It recovers the EIP-712 signer from the signature over the official offer-receipt typed data
(domain { name: 'x402 receipt', version: '1', chainId: 1 }) and checks recover === receipt.payTo.
This is the classic EIP-712 footgun handled for you: recoverTypedDataAddress never throws on a bad
signature — it returns a wrong address — so the equality is the real verification. A tampered
attestation recovers some other address → { ok: false }, never an exception.
What you get back:
ok: truewithsigner— the recovered address equalspayTo; delivery is attested by the key that received the money.ok: falsewith areason— and it never throws:no-attestation— the receipt carries no Tier-2 signature (it’s Tier-1 only).signer-mismatch(with the recoveredsigner) — a tampered or wrong-key signature.jws-not-loaded— the attestation is the reserved JWS format (R3, not yet implemented).invalid-signature/verify-failed— a malformed signature or recovery fault.
Like verifyReceipt, it’s static, wallet-free, and viem stays in a lazily-imported EVM
driver chunk (the protocol layer pulls no chain libs).
Anyone can verify — a third party with only an RPC
Section titled “Anyone can verify — a third party with only an RPC”Neither method needs the payer, a key, or a PipRail account. Hand a serialized PipRailReceipt to a
reviewer, an auditor, or a reputation system, and they verify it cold:
import { PipRailClient } from '@piprail/sdk'
// `handedReceipt` arrived over email / an attestation feed / a dispute filing — we never paid it.const v = await PipRailClient.verifyReceipt(handedReceipt, { rpcUrl: process.env.MY_RPC })const a = await PipRailClient.verifyAttestation(handedReceipt)
const trustworthy = v.ok && v.matchesClaims && a.ok // settled to payTo, by the claimed payer, servedThe receipt is the only input; the chain (via the verifier’s own RPC) is the only authority.
Worked end-to-end — capture → verify → attest → catch a forgery
Section titled “Worked end-to-end — capture → verify → attest → catch a forgery”import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'base', wallet: { key: process.env.AGENT_KEY! } })
// 1) Pay, then capture the receipt the gate emitted.await client.fetch('https://api.example.com/report')const receipt = client.lastReceipt()if (!receipt) throw new Error('the gate emitted no verifiable receipt (receipts option off)')
// 2) Re-verify Tier-1 against the chain — re-derives, never trusts the claims.const v = await PipRailClient.verifyReceipt(receipt)console.log(v.ok, v.matchesClaims) // true true — settled to payTo, by usconsole.log(v.onChain) // { payTo, asset, amount, payer } re-read from the tx
// 3) If the merchant attested delivery (Tier-2), check the signature → payTo.const a = await PipRailClient.verifyAttestation(receipt)console.log(a.ok, a.signer) // true, 0xMerchant… (or { ok:false, reason:'no-attestation' } for Tier-1)
// 4) A forged receipt is caught — tamper with the claimed payer.const forged = { ...receipt, receipt: { ...receipt.receipt, payer: '0xAttacker…' } }const f = await PipRailClient.verifyReceipt(forged)console.log(f.ok, f.matchesClaims) // true false — the payment is real, but not from 0xAttacker…From an agent — piprail_verify_receipt
Section titled “From an agent — piprail_verify_receipt”The MCP server exposes the same primitive as a read-only, key-less tool: hand it a
PipRailReceipt (from a prior piprail_pay_request result, or any third party) and it returns the
chain-recovered verdict — no wallet required.
See also
Section titled “See also”- Verifiable receipts (seller) — the emit side: turn receipts on, the two tiers, and the wire shape.
- PipRailClient — the client these methods hang off.