Dexter
Dexter
Docs
Pay for APIs

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:

pay.ts
import { payAndFetch, createKeypairWallet, type PayResult } from '@dexterai/x402/client';
 
const solana = await createKeypairWallet(process.env.SOLANA_PRIVATE_KEY!);
 
const result: PayResult = await payAndFetch(
  'https://api.example.com/protected',
  { method: 'GET' },
  { solana },
  {},
);
 
if (result.ok && result.paid) {
  if (result.response) {
    const data = await result.response.json();
    console.log('paid', result.amountPaid, result.network.bare, result.txSignature);
    void data;
  } else {
    // Payment confirmed settled, merchant never answered before the deadline.
    console.log('paid but no response — do not re-pay');
  }
} else if (result.ok && !result.paid) {
  const data = await result.response.json(); // endpoint was free
  void data;
} else if (!result.ok) {
  console.error(result.reason, result.detail);
}

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.

reasonWhat happenedMoney moved?Blind-retry
unsupported_networkNo wallet or asset for the chain the merchant demandsNoSafe
insufficient_fundsThe wallet cannot cover the amountNoSafe
no_payment_optionsThe 402 offered nothing this client can payNoSafe
budget_exceededA spend cap blocked signing before any paymentNoSafe
timeoutThe pre-payment deadline hit before anything was signed or sentNoSafe
merchant_rejectedThe merchant rejected the payment payload (declined or failed verification)No — rejected, not settledSafe from double-charge; inspect detail, a retry likely repeats
settlement_failedThe merchant accepted the payment shape but their own settlement erroredMerchant-side; not confirmedVerify first — this is a merchant-side defect, detail carries their error
payment_unconfirmedThe authorization was sent, no confirmation came back, and it may have settled on-chainMaybeUnsafe — a retry signs a fresh authorization and can pay twice
errorUnexpected or unclassified failureUnknownUnsafe — 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 get reason: '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 get reason: '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.

ClassThrown whenUseful fields
SessionScopeExceededErrorA buyer call would exceed the session key's cap or expiryreason: 'cap_exceeded' | 'expired' | 'wrong_counterparty'
TabClosedErrorYou operate against a tab that has been closedchannelId
UnsupportedNetworkErrorThe SDK is used against a chain it does not supportnetwork
LiveSessionExistsErrorYou open a tab against a (vault, seller) pair that already has a live session, without opting into replacedetails (on-chain frontier evidence)
import type { Tab } from '@dexterai/x402/tab';
import {
  LiveSessionExistsError,
  SessionScopeExceededError,
  TabClosedError,
  UnsupportedNetworkError,
} from '@dexterai/x402/tab';
 
declare const tab: Tab;
declare const url: string;
 
try {
  const chunks = await tab.stream(url);
  for await (const chunk of chunks) {
    void chunk;
  }
} catch (err) {
  if (err instanceof SessionScopeExceededError) {
    console.error('scope exceeded:', err.reason); // cap_exceeded | expired | wrong_counterparty
  } else if (err instanceof TabClosedError) {
    console.error('tab closed:', err.channelId);
  } else if (err instanceof UnsupportedNetworkError) {
    console.error('unsupported network:', err.network);
  } else if (err instanceof LiveSessionExistsError) {
    console.error('live session exists:', err.details.frontierAtomic);
  } else {
    throw err;
  }
}

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.

On this page