VerifyErrorCode
Introduction
Section titled “Introduction”VerifyErrorCode is the second of PipRail’s two error channels: the
returned one. Where a config or wallet problem throws a PipRailError,
the outcome of verifying an on-chain proof is returned — a driver’s verify() never throws for
a rejected payment, it returns a VerifyResult carrying one of these codes.
type VerifyResult = | { ok: true; receipt: X402Receipt } | { ok: false; error: VerifyErrorCode; detail: string }The set is closed — the compiler enforces it, so a driver can’t invent a code, and every
family uses the same code for the same condition. An agent branches on error; the human-readable
detail is for logs.
The codes
Section titled “The codes”| Code | Meaning | Transient? |
|---|---|---|
tx_not_found | The proof tx isn’t on chain yet (RPC lag), or a transient RPC read failed. | transient |
insufficient_confirmations | Mined, but fewer than minConfirmations deep. | transient |
tx_reverted | The tx is on chain but failed / reverted. | definitive |
no_meta | The tx carries no metadata to inspect. | definitive |
wrong_recipient | Paid, but not to payTo. | definitive |
amount_too_low | Paid to payTo, but less than required. | definitive |
transfer_not_found | No matching transfer (asset / amount / nonce) to payTo. | definitive |
payment_expired | Older than maxTimeoutSeconds (the replay window) — OR the proof’s on-chain timestamp is missing/non-finite, so its age can’t be bounded (recency fails closed, never open). | definitive |
tx_already_used | This proof was already redeemed — a replay. | definitive |
signature_invalid | The exact-rail authorization is invalid — the signed payload didn’t validate against the trusted rail. On EVM the EIP-712 signature didn’t recover to the payer; on Solana, Algorand, Aptos, and NEAR the signed transaction / atomic group / delegate-action is unparseable, the signer or structure is wrong, or it trips a fee-payer/relayer drain guard. | definitive |
upto_settle_exceeds_max | The upto (metered) rail: the merchant’s metered settle amount exceeds the maximum the buyer signed (permitted.amount). Gate/driver-enforced before any broadcast — nothing settles. | definitive |
Transient vs definitive
Section titled “Transient vs definitive”transient means the proof may simply not have propagated to the server’s RPC node yet;
definitive means retrying won’t change the outcome. These labels are informational — the
built-in client retries every code up to maxPaymentRetries with a short backoff that absorbs
RPC lag, and does not branch on the code. A consumer building a custom client may branch on it.
On the gate side the same split surfaces as FailedPayment.transient
(onFailed).
Why each code appears
Section titled “Why each code appears”Most codes map to a stage of proof binding — find the tx, confirm it, read the transfer, check it was unused.
tx_not_found— the only transient that all drivers emit. The proof ref didn’t resolve to a transaction on the merchant’s RPC: it hasn’t landed yet, or the read itself failed.insufficient_confirmations— the tx is mined but not yetminConfirmationsdeep. Emitted by families with a discrete confirmation depth (EVM and XRPL); the client retries after the backoff.tx_reverted— the transaction exists on chain but its execution failed, so nothing settled.no_meta— Solana-specific: the transaction returned no metadata to inspect, so the transfer can’t be read.wrong_recipient— a digest-bound transfer landed, but not to the merchant’spayTo.amount_too_low— the transfer reachedpayTobut paid less than the rail required.transfer_not_found— no transfer matching the asset, amount, and nonce was found onpayTo. On account-watch chains this also absorbs “wrong recipient” (see below).payment_expired— the proof is older than the rail’smaxTimeoutSecondsrecency window. On theexactrail, this is also an expired or not-yet-valid EIP-3009 authorization. It also fires when the proof’s on-chain timestamp is missing or non-finite (a degraded RPC): the age can’t be bounded, so the recency check fails closed ("Cannot bound the age … — failing closed.") rather than letting an unbounded-age proof through.tx_already_used— the verify-style code the gate emits for the onchain-proof replay set — and that the EVMexact/ Permit2 driver also returns via the token’s on-chainauthorizationState/ Permit2 nonce check.signature_invalid—exact-rail only: the signed authorization didn’t validate against the trusted rail. On EVM the EIP-712 signature didn’t recover to the claimed payer; on Solana, Algorand, Aptos, and NEAR the signed transaction / atomic group / delegate-action is unparseable, its signer or structure is wrong, or it exceeds the fee-payer/relayer drain-guard caps. See the exact rail.
Family-specificity is structural, not drift
Section titled “Family-specificity is structural, not drift”Some codes are emitted only by certain families because of how that chain is verified — not because of inconsistency.
| Behaviour | Why |
|---|---|
no_meta is Solana-only | Only Solana exposes a “no transaction metadata” condition. |
insufficient_confirmations is EVM/XRPL-style | It needs a discrete confirmation count. |
Account-watch chains (TON, Stellar) never say wrong_recipient | They scan the merchant account, so “wrong recipient” and “no payment” both collapse to transfer_not_found. |
EVM / Solana digest verifiers report a short token payment as transfer_not_found | The digest path has no nonce binding to anchor an amount_too_low; nonce-bound chains (TON, Stellar) can say amount_too_low. |
All of these are correct. See Payment driver architecture for the two verification templates behind the split.
What the agent receives
Section titled “What the agent receives”A rejected proof becomes a conformant v2 402 re-challenge: a full body with accepts[] (so a
standard client can retry), the human reason in error, and the machine code in
extensions.piprail.{code,detail}. The built-in requirePayment
adapter emits this automatically; the client relays the reason to the agent. When the client
finally gives up, MaxRetriesExceededError embeds the last server rejection — for example:
… Last server rejection: amount_too_low — Paid 40000, required 500000.