Wire-format codecs
Introduction
Section titled “Introduction”The high-level PipRailClient and
createPaymentGate cover the 99% case. These
are the raw codecs underneath them: pure functions that turn x402 envelopes into base64 header
values and back, with nothing chain-specific in them. Reach for these only when you’re building
a client or server by hand — a non-Node runtime, a custom transport, or a protocol bridge.
Everything here is exported from @piprail/sdk. The codecs are chain-agnostic: identifiers
round-trip as plain strings (CAIP-2 networks, base-unit amounts), and each
PaymentDriver interprets them for its own chain.
The three headers
Section titled “The three headers”PipRail’s onchain-proof flow is three base64-JSON headers, all lowercase, no X- prefix:
| Constant | Header | Direction | Carries |
|---|---|---|---|
HEADER_REQUIRED | payment-required | server → client | the 402 challenge |
HEADER_SIGNATURE | payment-signature | client → server | the payment proof |
HEADER_RESPONSE | payment-response | server → client | the receipt, on 200 |
import { HEADER_REQUIRED, HEADER_SIGNATURE, HEADER_RESPONSE } from '@piprail/sdk'The round-trip is symmetric per side:
server: buildChallengeHeader → (verify) → buildReceiptHeaderclient: parseChallenge → buildSignatureHeader → parseReceiptBuilding the challenge (server)
Section titled “Building the challenge (server)”A server emits a 402 by base64-encoding an X402Challenge into the payment-required header.
The challenge carries the resource it gates and an accepts[] array — one entry per rail you
offer. Only scheme / network / amount / asset / payTo / maxTimeoutSeconds are
top-level; the nonce, decimals, minConfirmations, and amountFormatted live under extra.
import { buildChallengeHeader, HEADER_REQUIRED, type X402Challenge } from '@piprail/sdk'
const challenge: X402Challenge = { x402Version: 2, resource: { url: 'https://api.example.com/report' }, accepts: [{ scheme: 'onchain-proof', network: 'eip155:8453', // CAIP-2 (Base) amount: '100000', // base units (0.10 USDC at 6 decimals) asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base USDC payTo: '0xYourWallet', maxTimeoutSeconds: 120, extra: { nonce: 'abc123', decimals: 6, minConfirmations: 1, amountFormatted: '0.10' }, }],}
res.status(402).setHeader(HEADER_REQUIRED, buildChallengeHeader(challenge))// buildChallengeHeader → a base64-JSON string for the payment-required headerbuildReceiptHeader(receipt) does the same for an X402Receipt on a successful 200 — see
Receipts.
Building the proof (client)
Section titled “Building the proof (client)”After paying on-chain, a client base64-encodes an X402PaymentSignature into the
payment-signature header and retries the request. The accepted field is the rail you chose,
echoed back verbatim from the challenge; the payload binds the challenge nonce to your proof
ref (txHash — an EVM tx hash, a Solana signature, a TON locator, …).
import { buildSignatureHeader, parseChallenge, pickAccept, HEADER_SIGNATURE,} from '@piprail/sdk'
const url = 'https://api.example.com/report'const res = await fetch(url)
const challenge = await parseChallenge(res)if (!challenge) throw new Error('not a valid x402 402')
const chosenRail = pickAccept(challenge, (network) => network === 'eip155:8453')if (!chosenRail) throw new Error('no rail I can pay')
// …pay chosenRail.amount of chosenRail.asset to chosenRail.payTo on-chain, then:const txHash = '0x4f8e1c0b9a2d3e6f7081a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f7'
const value = buildSignatureHeader({ x402Version: 2, accepted: chosenRail, // the X402AcceptEntry from the challenge payload: { nonce: chosenRail.extra.nonce, txHash },})// buildSignatureHeader → a base64-JSON string for the payment-signature header
await fetch(url, { headers: { [HEADER_SIGNATURE]: value } })How the nonce rides in the proof — and how verify() re-derives every checked field from the
trusted accept rather than this echo — is covered under proof binding.
Parsing (both sides)
Section titled “Parsing (both sides)”The parsers are tolerant: they read a base64 header value (or, for parseChallenge, a JSON
body fallback) and return a typed object or null. They never throw.
| Function | Reads | Returns |
|---|---|---|
parseChallenge(response) | payment-required header, then JSON body | X402Challenge | null |
parseSignatureHeader(value) | a payment-signature value | X402PaymentSignature | null |
parseReceipt(response) | payment-response header | X402Receipt | null |
import { parseChallenge, parseReceipt } from '@piprail/sdk'
const url = 'https://api.example.com/report'const res = await fetch(url)
if (res.status === 402) { const challenge = await parseChallenge(res) // X402Challenge | null if (!challenge) throw new Error('402 with no valid x402 challenge') // …choose a rail, pay, retry…}
const settledResponse = await fetch(url, { headers: { /* payment-signature */ } })const receipt = parseReceipt(settledResponse) // X402Receipt | null — null if no receipt headerif (receipt) { console.log(receipt.transaction, receipt.payer) // → { scheme, success: true, network, transaction, asset, amount, payer, payTo, verifiedAt }}parseChallenge is async because it may await response.clone().json() to fall back to a
JSON challenge body. parseSignatureHeader is what a hand-rolled server calls on the inbound
payment-signature; it returns null for anything that isn’t a well-formed onchain-proof
proof (so an exact payment falls through — see below).
Selecting a rail — pickAccept
Section titled “Selecting a rail — pickAccept”A 402 may offer several rails. pickAccept returns the first onchain-proof entry whose
network your predicate accepts, or null:
import { pickAccept, parseChallenge } from '@piprail/sdk'
const challenge = await parseChallenge(res)if (!challenge) throw new Error('not a valid x402 402')
const rail = pickAccept(challenge, (network) => network === 'eip155:8453')if (!rail) throw new Error('no rail I can pay')// → an X402AcceptEntry on the matched network, or nullThe predicate gets the raw CAIP-2 string, so you decide what “I can pay this” means — a single
chain, a family prefix (network.startsWith('solana:')), or a set you hold a wallet for. Only
onchain-proof rails are considered; exact rails are skipped.
The wire types
Section titled “The wire types”The codecs are typed by a small set of interfaces, all exported as types:
| Type | What it is |
|---|---|
X402Challenge | the 402 body: resource + accepts[] |
X402AcceptEntry | one onchain-proof rail in accepts[] |
X402AnyAccept | X402AcceptEntry | X402ExactAcceptEntry (a challenge entry, either rail) |
X402PaymentSignature | the client’s proof: accepted + { nonce, txHash } |
X402Receipt | the settled receipt on a 200 |
X402ResourceObject | the gated resource: url, optional description / mimeType |
Caip2 | a CAIP-2 network id, e.g. eip155:8453 |
AssetId | a chain-specific asset id, or 'native' |
AddressId | a chain-specific account id |
VerifyResult and VerifyErrorCode — the shape every driver’s
verify() returns — are exported here too. VerifyResult is the union
{ ok: true; receipt } | { ok: false; error: VerifyErrorCode; detail: string }.
import type { X402Challenge, X402AcceptEntry, X402PaymentSignature, X402Receipt, Caip2,} from '@piprail/sdk'Version posture — strict v2 out, liberal in
Section titled “Version posture — strict v2 out, liberal in”PipRail emits strict x402 v2 and accepts both v2 and v1. v2 replaced v1 on the wire
(the header moved X-PAYMENT → payment-signature, the challenge moved into the
payment-required header, networks became CAIP-2). The parsers still read v1 so that
agents and facilitators pinned to it keep working, which is why two legacy header constants
ship:
import { HEADER_SIGNATURE_V1, HEADER_RESPONSE_V1 } from '@piprail/sdk'// 'x-payment' and 'x-payment-response'parseReceipt reads payment-response, falling back to the v1 x-payment-response a foreign
server may set.
The standard exact rail (interop)
Section titled “The standard exact rail (interop)”A PipRail gate can dual-advertise a standard x402 exact rail alongside its
onchain-proof rail, so any off-the-shelf x402 client can pay it. That rail has its own
codecs — they’re advanced and live on the exact low-level page;
here’s the map:
| Function / type | Role |
|---|---|
X402ExactAcceptEntry | an exact rail in accepts[] (carries the EIP-712 domain) |
buildExactSignatureHeader({ accepted, payload }) | frame an EIP-3009 exact payment for the wire (buyer) |
parseExactPaymentHeader(value) | parse an inbound exact payment, normalised across v1/v2 (seller) |
ParsedExactPayment | what parseExactPaymentHeader returns |
ExactPaymentPayload / ExactAuthorizationWire | the { signature, authorization } payload |
parseExactPaymentHeader tolerates both the v2 payment-signature and the v1 X-PAYMENT
shapes; the network/asset it returns are the client’s claim, used only to match an
offered rail — the gate re-derives every verified field from its own trusted rail. See
selling the exact rail and the
exact buyer path.
Reading a foreign settle result — parseSettleResponse
Section titled “Reading a foreign settle result — parseSettleResponse”When a PipRail buyer pays a third-party exact server, that server replies with a standard
SettleResponse rather than a PipRail receipt. parseSettleResponse reads it from
payment-response (or the v1 fallback) into a SettleOutcome:
import { parseSettleResponse, type SettleOutcome } from '@piprail/sdk'
const outcome: SettleOutcome | null = parseSettleResponse(response)// → { success: boolean, transaction?, network?, payer?, errorReason? } | null
if (outcome && outcome.success === false) { // an explicit rejection — never record a spend on it throw new Error(`exact settle rejected: ${outcome.errorReason ?? 'unknown'}`)}The success flag is authoritative, and the distinction is load-bearing: null (no settle
body at all) means the server just served the resource — treat it as an affirmative 2xx
settlement. An explicit success: false is a real rejection — never record a spend on it.
Only a body with a boolean success is parsed; anything else returns null.