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). | definitive |
tx_already_used | This proof was already redeemed — a replay. | definitive |
signature_invalid | The exact-rail EIP-712 authorization didn’t recover to the payer. | 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.
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 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’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.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_invalid—exact-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.
| Behaviour | Why |
|---|---|
no_meta is Solana-only | Only Solana exposes a “no transaction metadata” condition. |
insufficient_confirmations is EVM-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.