Skip to content

Wire-format codecs

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.

PipRail’s onchain-proof flow is three base64-JSON headers, all lowercase, no X- prefix:

ConstantHeaderDirectionCarries
HEADER_REQUIREDpayment-requiredserver → clientthe 402 challenge
HEADER_SIGNATUREpayment-signatureclient → serverthe payment proof
HEADER_RESPONSEpayment-responseserver → clientthe receipt, on 200
import { HEADER_REQUIRED, HEADER_SIGNATURE, HEADER_RESPONSE } from '@piprail/sdk'

The round-trip is symmetric per side:

server: buildChallengeHeader → (verify) → buildReceiptHeader
client: parseChallenge → buildSignatureHeader → parseReceipt

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 header

buildReceiptHeader(receipt) does the same for an X402Receipt on a successful 200 — see Receipts.

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.

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.

FunctionReadsReturns
parseChallenge(response)payment-required header, then JSON bodyX402Challenge | null
parseSignatureHeader(value)a payment-signature valueX402PaymentSignature | null
parseReceipt(response)payment-response headerX402Receipt | 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 header
if (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).

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 null

The 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 codecs are typed by a small set of interfaces, all exported as types:

TypeWhat it is
X402Challengethe 402 body: resource + accepts[]
X402AcceptEntryone onchain-proof rail in accepts[]
X402AnyAcceptX402AcceptEntry | X402ExactAcceptEntry (a challenge entry, either rail)
X402PaymentSignaturethe client’s proof: accepted + { nonce, txHash }
X402Receiptthe settled receipt on a 200
X402ResourceObjectthe gated resource: url, optional description / mimeType
Caip2a CAIP-2 network id, e.g. eip155:8453
AssetIda chain-specific asset id, or 'native'
AddressIda 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-PAYMENTpayment-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.

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 / typeRole
X402ExactAcceptEntryan 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)
ParsedExactPaymentwhat parseExactPaymentHeader returns
ExactPaymentPayload / ExactAuthorizationWirethe { 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.