Verified against @dexterai/x402 5.3.1 · 2026-07-07
Errors & retry safety
By the end of this page you will know how to read a PayResult, which failures are safe to retry, and the one failure where a blind retry can pay twice.
payAndFetch never throws for an expected failure
payAndFetch returns a typed PayResult. It does not throw when a payment fails in an expected way — it returns a result you narrow on. There are three shapes:
{ ok: true, paid: true, ... }— the endpoint demanded payment and you paid.{ ok: true, paid: false, ... }— the endpoint was free; no payment was attempted.{ ok: false, reason, detail? }— the payment did not complete.
Narrow on ok, then on paid, before reading any payment field:
The reason table
Every ok: false result carries a reason and an optional detail string. Retry-safety here means one thing: can a blind retry cause a second charge.
reason | What happened | Money moved? | Blind-retry |
|---|---|---|---|
unsupported_network | No wallet or asset for the chain the merchant demands | No | Safe |
insufficient_funds | The wallet cannot cover the amount | No | Safe |
no_payment_options | The 402 offered nothing this client can pay | No | Safe |
budget_exceeded | A spend cap blocked signing before any payment | No | Safe |
timeout | The pre-payment deadline hit before anything was signed or sent | No | Safe |
merchant_rejected | The merchant rejected the payment payload (declined or failed verification) | No — rejected, not settled | Safe from double-charge; inspect detail, a retry likely repeats |
settlement_failed | The merchant accepted the payment shape but their own settlement errored | Merchant-side; not confirmed | Verify first — this is a merchant-side defect, detail carries their error |
payment_unconfirmed | The authorization was sent, no confirmation came back, and it may have settled on-chain | Maybe | Unsafe — a retry signs a fresh authorization and can pay twice |
error | Unexpected or unclassified failure | Unknown | Unsafe — read detail, do not blind-retry |
The load-bearing rule: timeout vs payment_unconfirmed
A paid fetch has two separate deadlines, and which one fires tells you whether money could have moved.
timeoutMs(default 15000) bounds everything before the payment authorization is sent — the unpaid probe and the build/sign step. If it fires you getreason: 'timeout'. No authorization was ever dispatched, so no money moved. Retry freely.responseTimeoutMs(default 120000) bounds the wait for the merchant's response after the authorization has been sent. If it fires you getreason: 'payment_unconfirmed'. The authorization is already out the door and may have settled on-chain. Aborting the wait does not un-spend the money.
timeout is retry-safe. payment_unconfirmed is not. A blind retry on payment_unconfirmed signs a new authorization and can settle a second payment for the same call. Do not auto-retry it — surface it, or reconcile against the wallet or merchant out of band, then decide. The ok: false branch gives you reason and detail only; it does not hand you a transaction signature, so a retry loop has no way to know the first attempt settled.
There is a related case on the success side: { ok: true, paid: true, response: undefined }. The payment was confirmed settled but the merchant did not answer before the deadline. You paid; you have no response. Treat it like payment_unconfirmed for retry purposes — do not re-pay to "get the response," because the money already moved. amountPaid, network, and txSignature are available on this branch to reconcile.
Tab error classes
The per-call PayResult above is the buyer's per-request result. The tab layer is different: tab operations throw typed errors instead of returning a PayResult. They come from @dexterai/x402/tab.
| Class | Thrown when | Useful fields |
|---|---|---|
SessionScopeExceededError | A buyer call would exceed the session key's cap or expiry | reason: 'cap_exceeded' | 'expired' | 'wrong_counterparty' |
TabClosedError | You operate against a tab that has been closed | channelId |
UnsupportedNetworkError | The SDK is used against a chain it does not support | network |
LiveSessionExistsError | You open a tab against a (vault, seller) pair that already has a live session, without opting into replace | details (on-chain frontier evidence) |
LiveSessionExistsError is loud on purpose. Replacing a live session zeroes its on-chain registration, and any voucher the old session key signed beyond the settled frontier becomes unsettleable — it would strand the seller's unsecured tail. The SDK refuses to replace silently. Settle the old tab first (tab.close()), or open with onLiveSession: 'replace' only when the old tail is known-settled or intentionally abandoned.
These tab errors are distinct from HTTP-level facilitator errors. A 4xx/5xx from the facilitator is a transport-layer failure, not one of these classes; do not conflate the two layers.