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).definitive
tx_already_usedThis proof was already redeemed — a replay.definitive
signature_invalidThe exact-rail EIP-712 authorization didn’t recover to the payer.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.

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 count (EVM); 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.
  • tx_already_used — the proof was already redeemed. This is the one verify-style code emitted by the gate, not a driver, because only the gate owns the used-proof set.
  • signature_invalidexact-rail only: the EIP-712 authorization signature didn’t recover to the claimed payer. 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-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.