Skip to content

VerifyErrorCode

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.

CodeMeaningTransient?
tx_not_foundThe proof tx isn’t on chain yet (RPC lag), or a transient RPC read failed.transient
insufficient_confirmationsMined, but fewer than minConfirmations deep.transient
tx_revertedThe tx is on chain but failed / reverted.definitive
no_metaThe tx carries no metadata to inspect.definitive
wrong_recipientPaid, but not to payTo.definitive
amount_too_lowPaid to payTo, but less than required.definitive
transfer_not_foundNo matching transfer (asset / amount / nonce) to payTo.definitive
payment_expiredOlder 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_usedThis proof was already redeemed — a replay.definitive
signature_invalidThe 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_maxThe 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 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).

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 yet minConfirmations deep. 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’s payTo.
  • amount_too_low — the transfer reached payTo but paid less than the rail required.
  • transfer_not_found — no transfer matching the asset, amount, and nonce was found on payTo. On account-watch chains this also absorbs “wrong recipient” (see below).
  • payment_expired — the proof is older than the rail’s maxTimeoutSeconds recency window. On the exact rail, 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 EVM exact / Permit2 driver also returns via the token’s on-chain authorizationState / Permit2 nonce check.
  • signature_invalidexact-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.

BehaviourWhy
no_meta is Solana-onlyOnly Solana exposes a “no transaction metadata” condition.
insufficient_confirmations is EVM/XRPL-styleIt needs a discrete confirmation count.
Account-watch chains (TON, Stellar) never say wrong_recipientThey 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_foundThe 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.

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.