Verifying payments
Introduction
Section titled “Introduction”gate.verify(headerValue) is the one call that decides whether a request has paid. You hand it
the incoming payment-signature header; it reads the proof’s transaction on-chain, against
your own RPC, and returns a structured VerifyPaymentResult you branch on. There is no
facilitator, no PipRail server, no callback to a third party — verification runs in your
process. (The Express middleware requirePayment
wraps exactly this; reach for the gate when you’re on Hono, Fastify, Workers, or anything else.)
Calling verify
Section titled “Calling verify”Build one gate per gated resource and reuse it — its in-memory used-proof set is what stops a proof being redeemed twice. Pass the header value (a missing header is fine — it just means “first request, no proof yet”).
import { createPaymentGate } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet',})
const result = await gate.verify(req.headers['payment-signature'])// → { kind: 'paid' | 'challenge' | 'invalid', … }The VerifyPaymentResult
Section titled “The VerifyPaymentResult”verify() returns the discriminated union VerifyPaymentResult — switch on kind:
switch (result.kind) { case 'paid': /* 200 + the resource */ break case 'challenge': /* 402 + result.challenge (first request) */ break case 'invalid': /* 402 + result.challenge (proof rejected) */ break}kind | When | What to send back |
|---|---|---|
'paid' | A valid, recent, unused proof verified on-chain | 200 + the resource, and the result.receiptHeader as the payment-response header |
'challenge' | No proof on the request yet | 402 + result.challenge (and result.requiredHeader as the payment-required header) |
'invalid' | A proof was submitted but failed verification | 402 + result.challenge — the rejection carries a fresh re-challenge so a standard client can retry |
Each variant carries the headers you need to set. These are illustrative shapes of the
VerifyPaymentResult union — branch on kind and TypeScript narrows to the right one:
// kind: 'paid' → { receipt: X402Receipt; receiptHeader: string }// kind: 'challenge' → { challenge: X402Challenge; requiredHeader: string; statusCode: 402 }// kind: 'invalid' → { error: string; detail: string;// challenge: X402Challenge; requiredHeader: string; statusCode: 402 }The receipt
Section titled “The receipt”On 'paid', result.receipt is the verified settlement — every field re-derived from the
trusted accept, not from the client’s submission:
interface X402Receipt { scheme: 'onchain-proof' | 'exact' success: true network: string // CAIP-2, e.g. 'eip155:8453' transaction: string // the verified on-chain tx id (settled payment) asset: string amount: string // base units payer: string payTo: string verifiedAt: string}Set result.receiptHeader as the payment-response header so the client gets its receipt, then
serve the resource. To log or fulfil on every settled payment, prefer the
onPaid callback — it fires once after verification
succeeds.
What “verified” means
Section titled “What “verified” means”A proof reaches 'paid' only if all of these hold, each checked against the chain itself:
- The proof’s transaction exists on-chain and didn’t revert.
- It paid the right asset and amount to your
payTo— re-derived from the server’s own resolved spec, so a forgedacceptedecho can’t redirect the check. - It has at least
minConfirmationsconfirmations (default1). - It landed inside the recency window — no older than
maxTimeoutSeconds(default600). - Its proof ref hasn’t been redeemed before (replay protection).
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet', minConfirmations: 3, // wait for more confirmations before granting access maxTimeoutSeconds: 300, // tighten the replay / recency window to 5 minutes})See Proof binding for how each family ties a proof to its challenge, and Replay protection for the used-proof set.
Why a proof was rejected
Section titled “Why a proof was rejected”The driver’s verify() returns a VerifyResult — { ok: false, error, detail } — where error
is a closed VerifyErrorCode. The gate carries it onto the 'invalid' result (result.error is
the code, result.detail the human text) and stamps it into the re-challenge’s
extensions.piprail.{ code, detail }. Branch on the code rather than parsing prose:
VerifyErrorCode | Meaning |
|---|---|
tx_not_found | Proof tx not on-chain yet — usually RPC lag (transient). |
insufficient_confirmations | Mined, but fewer than minConfirmations (transient, EVM). |
tx_reverted | The tx is on-chain but failed / reverted. |
wrong_recipient | Paid, but not to payTo. |
amount_too_low | Paid to payTo, but less than required. |
transfer_not_found | No matching transfer (asset / amount / nonce) to payTo. |
payment_expired | Older than maxTimeoutSeconds — outside the recency window. |
tx_already_used | The proof was already redeemed (replay) — emitted by the gate. |
no_meta | The tx carries no metadata to inspect (Solana). |
signature_invalid | exact rail only: the EIP-712 authorization didn’t recover to the payer. |
Some codes are family-specific by design (account-watch chains collapse “wrong recipient” into
transfer_not_found; insufficient_confirmations needs a discrete confirmation count). The full
table lives on the VerifyErrorCode reference.
Reading the verdict in your handler
Section titled “Reading the verdict in your handler”verify() never throws on a bad payment — a rejected proof comes back as kind: 'invalid',
not an exception. The one thing that does throw is a server-side settlement failure on the
exact rail (covered below). Here’s a complete Hono handler that covers all three verdicts plus
that one throw:
import { createPaymentGate, SettlementError } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet',})
app.get('/report', async (c) => { let result try { result = await gate.verify(c.req.header('payment-signature')) } catch (err) { // A valid `exact` payment that couldn't be SETTLED server-side (relayer out of // gas, facilitator down). NOT the payer's fault — answer 5xx, never 402. if (err instanceof SettlementError) return c.json({ error: 'settlement_failed' }, 502) throw err }
switch (result.kind) { case 'paid': c.header('payment-response', result.receiptHeader) return c.json({ report: 'your data' }) // 200 + the resource
case 'challenge': case 'invalid': // Both send back a full v2 re-challenge so a standard client can retry. c.header('payment-required', result.requiredHeader) return c.json(result.challenge, 402) }})Verifying the exact rail
Section titled “Verifying the exact rail”If you opted into the standard exact rail, the same
verify() call also handles inbound EIP-3009 payments — it parses either the v2
payment-signature or the legacy v1 x-payment header and routes accordingly. You don’t change
how you call it. One extra failure mode applies: when a valid, simulated exact payment can’t be
settled server-side (your relayer is out of gas, or a chosen facilitator is down), verify()
throws a SettlementError instead of returning 'invalid'. That’s not the payer’s fault — their
authorization stays valid and unused — so your adapter should answer 5xx, never 402:
import { createPaymentGate, SettlementError } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet', exact: { settle: 'self', relayer: { privateKey: process.env.AGENT_KEY } },})
let resulttry { result = await gate.verify(headerValue)} catch (err) { if (err instanceof SettlementError) return c.json({ error: 'settlement_failed' }, 502) throw err}The built-in requirePayment middleware does this for you (it answers 502 on a
SettlementError). For framework wiring patterns, see
Framework adapters.
Legacy invalid body
Section titled “Legacy invalid body”X402InvalidBody is the deprecated minimal rejection shape that the toInvalidBody(result)
helper returns:
interface X402InvalidBody { x402Version: 2 status: 'invalid' error: string detail: string}It carries no accepts[], so a standard x402 client that receives it can’t retry. The gate’s
kind: 'invalid' result already gives you a fully conformant re-challenge (result.challenge) —
emit that instead. toInvalidBody remains only for back-compat with hand-rolled adapters; new
code should never reach for it.