Dexter
Dexter
Docs
Pay for APIs

Verified against @dexterai/x402 5.3.1 · 2026-07-07

Pay per call from a browser wallet

By the end of this page you will have a React button that calls a 402-gated endpoint, prompts the user's connected wallet to pay in USDC, and hands you the paid response. The Solana path is the worked example. The EVM path is the same hook with a different wallet object, covered at the end.

What you need

  • A running React app (18 or 19).
  • An endpoint that returns HTTP 402 with x402 payment terms. If you don't have one yet, build it from Get paid — sell with tabs.
  • A wallet the user connects in the browser. On Solana that is any wallet-adapter wallet (Phantom, Solflare, Backpack). On EVM that is any wagmi connector.
  • USDC in that wallet on the network the endpoint charges on.

The wallet signs. Your app never holds a key.

Install

npm install @dexterai/x402 @solana/wallet-adapter-react @solana/web3.js

react and @solana/wallet-adapter-base are peer dependencies, so a normal wallet-adapter setup already satisfies them.

The hook

useX402Payment takes a set of wallets and returns a fetch that pays when it hits a 402. You call that fetch exactly like the global one. The first response is a 402, the hook builds and signs the payment with the wallet you passed, retries, and resolves with the real 200.

It needs a wallet object per chain. The Solana shape is two members:

interface SolanaWallet {
  publicKey: { toBase58(): string } | null;
  signTransaction<T>(tx: T): Promise<T>;
}

A wallet-adapter wallet already has both. There is one type seam: wallet-adapter constrains signTransaction to Solana transaction types, and SolanaWallet declares it generic, so you bridge them with a single cast at the boundary. The component below shows exactly that.

Solana component

PayButton.tsx
'use client';
 
import { useMemo } from 'react';
import { useWallet } from '@solana/wallet-adapter-react';
import { useX402Payment } from '@dexterai/x402/react';
import type { SolanaWallet } from '@dexterai/x402/adapters';
 
const ENDPOINT = 'https://your-api.example.com/paid/route';
 
export function PayButton() {
  const { publicKey, signTransaction, connected } = useWallet();
 
  // Adapt the wallet-adapter wallet to the shape the hook expects.
  const solana = useMemo<SolanaWallet | undefined>(() => {
    if (!publicKey || !signTransaction) return undefined;
    return {
      publicKey,
      signTransaction: signTransaction as SolanaWallet['signTransaction'],
    };
  }, [publicKey, signTransaction]);
 
  const {
    fetch: payAndFetch,
    isLoading,
    status,
    error,
    transactionUrl,
    balances,
    reset,
  } = useX402Payment({ wallets: { solana } });
 
  async function handleClick() {
    reset();
    const res = await payAndFetch(ENDPOINT);
    const data = await res.json();
    console.log('paid response', data);
  }
 
  return (
    <div>
      <button onClick={handleClick} disabled={!connected || isLoading}>
        {isLoading ? 'Paying…' : 'Pay and fetch'}
      </button>
      {status === 'error' && error ? <p role="alert">{error.message}</p> : null}
      {transactionUrl ? <a href={transactionUrl}>View transaction</a> : null}
      {balances.map((b) => (
        <span key={b.network}>{b.balance} {b.asset}</span>
      ))}
    </div>
  );
}

The solana object is undefined until the user connects, and the button stays disabled while it is. Once connected, one click runs the whole 402 round trip.

useX402Payment must run inside your wallet-adapter provider tree (ConnectionProvider and WalletProvider), because useWallet does. Put PayButton anywhere below those providers.

What the hook returns

You destructure only what you need. The full return:

FieldTypeUse
fetch(input, init?) => Promise<Response>Same signature as global fetch; pays on 402.
isLoadingbooleanA payment is in progress.
status'idle' | 'pending' | 'success' | 'error'Current flow state.
errorError | nullSet when the last call failed.
transactionIdstring | nullSignature or hash of the settled payment.
transactionUrlstring | nullExplorer link for that transaction.
transactionNetworkstring | nullCAIP-2 network the payment settled on.
balancesBalanceInfo[]{ network, chainName, balance, asset } per connected chain.
connectedChains{ solana: boolean; evm: boolean }Which chains have a wallet.
isAnyWalletConnectedbooleanTrue if either wallet is set.
reset() => voidClear error and transaction fields.
refreshBalances() => Promise<void>Re-read balances on demand.

Call reset() before a retry so a stale error doesn't linger in your UI.

EVM

The hook is chain-agnostic. Pass an evm wallet instead of (or alongside) solana. The EVM shape signs EIP-712 typed data:

interface EvmWallet {
  address: string;
  signTypedData?(params: {
    domain: Record<string, unknown>;
    types: Record<string, unknown[]>;
    primaryType: string;
    message: Record<string, unknown>;
  }): Promise<string>;
}

wagmi's useAccount() returns an address and connection state, not a signer. An address alone cannot sign an x402 payment. Build the evm wallet from useWalletClient(), which gives you a viem client with signTypedData.

PayButtonEvm.tsx
'use client';
 
import { useMemo } from 'react';
import { useWalletClient } from 'wagmi';
import { useX402Payment } from '@dexterai/x402/react';
import type { EvmWallet } from '@dexterai/x402/adapters';
 
const ENDPOINT = 'https://your-api.example.com/paid/route';
 
export function PayButtonEvm() {
  const { data: walletClient } = useWalletClient();
 
  const evm = useMemo<EvmWallet | undefined>(() => {
    if (!walletClient) return undefined;
    return {
      address: walletClient.account.address,
      signTypedData: (params) =>
        walletClient.signTypedData({ account: walletClient.account, ...params }),
    };
  }, [walletClient]);
 
  const { fetch: payAndFetch, isLoading, error, transactionUrl } =
    useX402Payment({ wallets: { evm } });
 
  return (
    <button onClick={() => payAndFetch(ENDPOINT)} disabled={!evm || isLoading}>
      {isLoading ? 'Paying…' : 'Pay and fetch'}
      {error ? ` — ${error.message}` : ''}
      {transactionUrl ? ' — done' : ''}
    </button>
  );
}

You can pass both wallets at once — useX402Payment({ wallets: { solana, evm } }) — and the hook settles on whichever network the endpoint's 402 asks for.

When it fails

A 402 round trip can fail on a wrong network, an empty balance, or a facilitator error. The hook surfaces those on error and flips status to 'error'. Map codes to messages in Pay — handling errors.

On this page