# x402 tutorial: your first AI agent that pays (and the trace that shows it)

> x402 tutorial for beginners: give an AI SDK agent a wallet, make it pay for an API call with x402 on a local chain, and see every payment in a trace.

Source: https://hashspan.dev/docs/x402-tutorial

This x402 tutorial takes you from no wallet to an AI agent that pays for an API call, and shows every payment as a trace. Everything runs on a local chain on your machine: no sign-up, no faucet, no real money.

- **Part 1:** give an [AI SDK](https://ai-sdk.dev) agent a wallet, watch it fail with no funds, fund it, and pay.
- **Part 2:** the agent pays 0.001 of a test token per request with x402. It only signs; someone else sends the transaction.
- **Each step** ends with a checkpoint and the trace that shows what happened.

The complete, CI-tested version of this code is [examples/x402-agent](https://github.com/selimaytac/hashspan/tree/main/examples/x402-agent) in the hashspan repository. [Run it instead](#want-the-complete-ci-tested-version) if you'd rather clone than type.

Know wallets and gas already? [Jump to Part 2](#part-2-how-does-an-agent-pay-for-an-api-call-with-x402). It builds on Part 1's setup: Foundry with Anvil running, Jaeger, and the project from steps 1 to 4 (`npm init`, `telemetry.ts`, `wallet.ts`, `model.ts`).

**Before you start:**
- **You need:** Node.js 22.3 or later, Docker, a terminal and about 30 minutes. On Windows, run everything in WSL: Foundry's installer needs WSL or Git Bash, and doesn't support PowerShell or Command Prompt.
- **No real money.** Both parts use Anvil, a private chain on your machine, and test tokens. No credit card, no API key.
- **Tested** on 2026-10-19 with Node.js 22.14, Foundry 1.8.5, solc 0.8.28, `ai` 7.0.143, viem 2.57.7, `@x402/*` 2.28.0, `@hashspan/viem` 1.3.0, `@hashspan/x402` 1.2.1 and Jaeger 2.21.0.

Figure: What you build, all on a local chain. Part 1: the agent's pay_vendor tool signs and sends 0.005 ETH to a vendor, and your wallet pays the gas; Jaeger shows execute_tool pay_vendor with a send 31337 and a confirm 31337 span with status success. Part 2, x402: the get_weather tool only signs, a facilitator sends the 0.001 TUSD payment to the paid API and pays the gas; Jaeger shows execute_tool get_weather with a payment 31337 span with status settled and a confirm 31337 span, and no send span.

By the end you can:

1. Create a wallet and keep its key out of your repository and your prompts.
2. Fund it, and tell ETH, which pays for gas, from a token, which pays for things.
3. Give an agent a tool that pays, with a cap on what it can spend.
4. Read a payment in a trace, and spot three failures in it.

## Part 1: how do you give an AI agent a wallet?

A wallet is a private key. The key is a secret number; the address is derived from it and is safe to share.

Whoever holds the key can spend what the address holds. So the key lives in a file only you can read, and never in your code, your repository or a prompt.

Part 1 runs on Anvil, a private blockchain on your machine. It starts empty, mines instantly and forgets everything when you stop it. Its chain id is 31337.

1. ### Start a local chain and Jaeger

Install Foundry, which ships Anvil and the `cast` command line tool. The installer puts it in `~/.foundry/bin`. Add that folder to your `PATH` in your shell profile (`~/.zshrc` here, `~/.bashrc` on bash), so every terminal finds it:

   ```sh
   curl -L https://foundry.paradigm.xyz | bash
   echo 'export PATH="$PATH:$HOME/.foundry/bin"' >> ~/.zshrc
   export PATH="$PATH:$HOME/.foundry/bin"
   foundryup
   ```

Then start the chain in one terminal. It listens on localhost:8545:

   ```sh
   anvil
   ```

In a second terminal, start Jaeger, a trace viewer:

   ```sh wrap
   docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:2.21.0
   ```

**Checkpoint:** in a third terminal, `cast block-number` prints `0`, and [localhost:16686](http://localhost:16686) opens Jaeger.

2. ### Create the project

   ```sh wrap
   mkdir first-paying-agent && cd first-paying-agent
   npm init -y && npm pkg set type=module
   npm install ai @ai-sdk/otel viem zod @hashspan/viem @opentelemetry/api @opentelemetry/sdk-trace-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto @opentelemetry/resources @opentelemetry/semantic-conventions tsx
   printf 'node_modules\n*.hex\n' > .gitignore
   ```

The last line keeps key files (`*.hex`) out of git.

Next, tracing. A **span** is one timed piece of work with a name and some attributes, such as "the tool call" or "sending the transaction".

A **trace** is the tree of spans for one run of the agent. [Traces and spans](https://hashspan.dev/learn/otel-traces-and-spans) shows one in three minutes.

Add `telemetry.ts`. It sends the agent's spans and hashspan's spans to Jaeger, through one OpenTelemetry provider:

   ```ts title="telemetry.ts"
   import { OpenTelemetry } from '@ai-sdk/otel';
   import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
   import { resourceFromAttributes } from '@opentelemetry/resources';
   import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
   import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
   import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
   import { registerTelemetry } from 'ai';

   export const provider = new NodeTracerProvider({
     resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'first-paying-agent' }),
     // Sends spans to Jaeger on localhost:4318
     spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],
   });
   provider.register();

   // The AI SDK's own spans (invoke_agent, execute_tool) use the same provider.
   registerTelemetry(new OpenTelemetry());
   ```

3. ### Give the agent a wallet

`wallet.ts` loads a key from a file, or makes a new one there the first time:

   ```ts title="wallet.ts"
   import { existsSync, readFileSync, writeFileSync } from 'node:fs';
   import { type Hex } from 'viem';
   import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';

   // Reads the private key from `file`, or creates a new one there.
   // Only you can read the file (mode 600). Never commit it, never paste it in a prompt.
   export function loadAccount(file: string) {
     if (!existsSync(file)) writeFileSync(file, generatePrivateKey(), { mode: 0o600 });
     return privateKeyToAccount(readFileSync(file, 'utf8').trim() as Hex);
   }
   ```

`balance.ts` prints the address and what it holds:

   ```ts title="balance.ts"
   import { createPublicClient, formatEther, http } from 'viem';
   import { foundry } from 'viem/chains';
   import { loadAccount } from './wallet.js';

   const account = loadAccount('agent-wallet.hex');
   const reader = createPublicClient({ chain: foundry, transport: http() });
   const balance = await reader.getBalance({ address: account.address });
   console.log(`address: ${account.address}`);
   console.log(`balance: ${formatEther(balance)} ETH`);
   ```

**Checkpoint:** `npx tsx balance.ts` prints a new address and `balance: 0 ETH`. Yours differs from mine:

   ```text
   address: 0xfA818720ae6029D0F76C84A377cA6cD610d40dDb
   balance: 0 ETH
   ```

**What just happened:**
viem made a random private key and wrote it to `agent-wallet.hex`. The address comes from the key, so the same file always gives the same address. Nothing was sent to the chain: a new address exists before anyone pays it.

4. ### Let the agent pay with an empty wallet

The agent gets one tool, `pay_vendor`. The tool refuses anything over 0.01 ETH before it signs: a cap in your own code is the first safety net, whatever the model asks for.

   ```ts title="agent.ts"
   import { provider } from './telemetry.js';
   import { withHashspan } from '@hashspan/viem';
   import { stepCountIs, tool, ToolLoopAgent } from 'ai';
   import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
   import { foundry } from 'viem/chains';
   import { z } from 'zod';
   import { scriptedModel } from './model.js';
   import { loadAccount } from './wallet.js';

   const hashspan = withHashspan({ agent: { name: 'first-paying-agent' } });
   const account = loadAccount('agent-wallet.hex');
   // Anvil mines at once, so poll for the receipt every 250 ms instead of viem's default 4 s.
   const options = { chain: foundry, transport: http(), pollingInterval: 250 };
   const wallet = createWalletClient({ account, ...options }).extend(hashspan);
   const reader = createPublicClient(options).extend(hashspan);

   const VENDOR = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; // Anvil's second account
   const MAX_ETH = '0.01';

   const payVendor = tool({
     description: `Pay the vendor in ETH. At most ${MAX_ETH} ETH per call.`,
     inputSchema: z.object({ amountEth: z.string() }),
     execute: async ({ amountEth }) => {
       const value = parseEther(amountEth);
       if (value > parseEther(MAX_ETH)) throw new Error(`Refused: over ${MAX_ETH} ETH`);
       const hash = await wallet.sendTransaction({ to: VENDOR, value });
       const receipt = await reader.waitForTransactionReceipt({ hash });
       return { hash, status: receipt.status };
     },
   });

   const agent = new ToolLoopAgent({
     model: scriptedModel('pay_vendor', { amountEth: '0.005' }),
     telemetry: { functionId: 'first-paying-agent' },
     instructions: 'You pay vendors with the pay_vendor tool.',
     tools: { pay_vendor: payVendor },
     stopWhen: stepCountIs(3),
   });

   const result = await agent.generate({ prompt: 'Pay the vendor 0.005 ETH.' });
   console.log(result.text);

   await hashspan.flush();
   await provider.shutdown();
   ```

`.extend(hashspan)` is the only tracing code. The AI SDK runs each tool inside an `execute_tool` span, and hashspan puts its spans under whatever span is active.

The model is scripted, so you need no API key. It calls the tool once, then repeats what the tool returned. `model.ts` below holds it.

To use a real LLM instead, swap the `model` line for an [AI SDK provider](https://ai-sdk.dev/docs/foundations/providers-and-models). With [OpenAI](https://ai-sdk.dev/providers/ai-sdk-providers/openai), for example, `npm install @ai-sdk/openai`, set `OPENAI_API_KEY`, and:

   ```ts
   import { openai } from '@ai-sdk/openai';
   // in the ToolLoopAgent options, instead of scriptedModel(...):
   model: openai('model-id'), // a model id from the provider page
   ```

hashspan's spans come from the payment calls inside the tool, not from the model. So the trace has the same shape whichever model calls the tool. If a real model takes another path, such as asking a question instead of paying, the trace shows that too.

   ```ts title="model.ts"
   import { MockLanguageModelV4 } from 'ai/test';

   const usage = {
     inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
     outputTokens: { total: 0, text: 0, reasoning: 0 },
   };

   // Stands in for an LLM, so the tutorial runs without an API key.
   // Turn 1: call `tool` with `input`. Turn 2: report what the tool returned.
   export function scriptedModel(tool: string, input: object) {
     return new MockLanguageModelV4({
       provider: 'scripted',
       modelId: 'scripted-model',
       doGenerate: async ({ prompt }) => {
         const last = prompt.at(-1);
         if (last?.role !== 'tool') {
           return {
             content: [{ type: 'tool-call', toolCallId: 'call_1', toolName: tool, input: JSON.stringify(input) }],
             finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
             usage,
             warnings: [],
           };
         }
         const result = last.content.find((part) => part.type === 'tool-result');
         return {
           content: [{ type: 'text', text: `Tool result: ${JSON.stringify(result?.output)}` }],
           finishReason: { unified: 'stop', raw: 'stop' },
           usage,
           warnings: [],
         };
       },
     });
   }
   ```

Run it with the empty wallet: `npx tsx agent.ts`.

**Checkpoint:** the tool fails. The output starts with:

   ```text wrap
   Tool result: {"type":"error-text","value":"TransactionExecutionError: The total cost (gas * gas fee + value) of executing this transaction exceeds the balance of the account.
   ```

In Jaeger, pick the service `first-paying-agent` and open the trace. The drawings on this page are simplified: Jaeger also shows a `step 1` span with a `chat scripted-model` span in it, and `execute_tool` sits under `step 1`, one level deeper.

Trace (agent.ts with 0 ETH, simplified · durations from the run):

- `invoke_agent scripted-model`: 53 ms
  - `execute_tool pay_vendor`: 47 ms · error
    - `send 31337`: 34 ms · InsufficientFundsError

The send span carries error.type = InsufficientFundsError, and the tool span is marked as an error.

**What just happened:**
Every transaction costs gas, paid in ETH by the sender, on top of the value sent. The wallet held nothing, so viem refused to send.

Nothing reached the chain, and there is no `confirm` span. [Insufficient funds](https://hashspan.dev/errors/insufficient-funds) explains this error, and [the error decoder](https://hashspan.dev/errors/decode) reads any message like it.

5. ### Fund the wallet

On a testnet, funds come from a faucet. On Anvil you set the balance yourself. Use your address from step 3:

   ```sh
   cast rpc anvil_setBalance 0xYourAddress 1000000000000000000
   ```

It prints `null`: the call returns nothing. The amount is in wei: 10<sup>18</sup> wei is 1 ETH.

**Checkpoint:** `npx tsx balance.ts` prints `balance: 1 ETH`.

6. ### Pay again and read the trace

Run `npx tsx agent.ts` again.

**Checkpoint:** the tool returns a hash and `"status":"success"`, and `npx tsx balance.ts` prints `balance: 0.994958 ETH`.

Trace (agent.ts with 1 ETH, simplified · durations from the run):

- `invoke_agent scripted-model`: 311 ms
  - `execute_tool pay_vendor`: 301 ms
    - `send 31337`: 30 ms
    - `confirm 31337`: 269 ms · success

send ends when the hash comes back. confirm ends when the receipt does, and links back to its send span.

The `confirm 31337` span holds the receipt:

   | Attribute | Value |
   |---|---|
   | `blockchain.tx.status` | `success` |
   | `blockchain.tx.gas.used` | `21000` |
   | `blockchain.tx.effective_gas_price` | `2000000000` (2 gwei) |
   | `blockchain.tx.fee` | `42000000000000` (0.000042 ETH) |

**What just happened:**
The wallet signed a transaction and sent it. Anvil put it in block 1 and wrote a receipt. The fee is gas used times gas price: 21,000 × 2 gwei.

That is why 1 ETH became 0.994958, not 0.995. [Transaction lifecycle](https://hashspan.dev/learn/tx-lifecycle) covers the steps, and [span links](https://hashspan.dev/learn/otel-span-links) explains the link from confirm to send.

A receipt can also say `reverted`: the transaction was mined, but the contract refused it. That is a value, not an exception, so your tool carries on unless it checks `status`. The [viem OpenTelemetry page](https://hashspan.dev/docs/viem-opentelemetry#what-happens-when-a-writecontract-reverts) shows how that looks in a trace.

## Part 2: how does an agent pay for an API call with x402?

x402 puts a price on an HTTP request. The server answers `402 Payment Required` with a price. The client signs a payment and sends the request again.

A third party, the facilitator, checks the signature and moves the money on chain.

Figure: One x402 payment in four steps between the agent, the paid API and the facilitator: 1, the agent sends GET /weather and gets 402 with a PAYMENT-REQUIRED header naming the price, the asset and payTo; 2, the agent signs a token authorization, which is no transaction and costs no gas; 3, it sends the GET again with a PAYMENT-SIGNATURE header and the API asks the facilitator to verify and settle; 4, the facilitator sends the transfer and pays the gas, and the API answers 200 with a PAYMENT-RESPONSE header that carries the transaction hash.

The big difference from Part 1 is step 2. The agent signs, but sends nothing. So it needs the token it pays with, and no ETH at all.

Part 2 builds on Part 1. You need Anvil and Jaeger running, and the project from Part 1 steps 1 to 4: `npm init`, `telemetry.ts`, `wallet.ts` and `model.ts`.

On a public network, the token would be USDC and the facilitator a hosted service. Here you deploy a test token and run the facilitator yourself, so nothing leaves your machine. Three words you meet around x402, in one line each:

- **Testnet:** a public blockchain for testing, whose coins have no value.
- **Faucet:** a website that gives out testnet coins for free.
- **Stablecoin:** a token that tracks a currency, such as USDC, where 1 USDC tracks 1 US dollar.

**Local test keys only:**
The keys in this tutorial are throwaway keys for a local chain. Never put a key that holds real money into this code, and never point it at a mainnet.

1. ### Deploy a test token

A token is a contract that keeps a balance per address. The x402 payment here uses [EIP-3009](https://eips.ethereum.org/EIPS/eip-3009): the holder signs a transfer, and anyone can submit it.

USDC supports it. Test USD is a small one from the hashspan repository, made for tests: anyone can mint it.

   ```sh wrap
   curl -sLO "https://raw.githubusercontent.com/selimaytac/hashspan/%40hashspan/x402%401.2.1/packages/x402/test/token/TestUsd.sol"
   forge create TestUsd.sol:TestUsd --broadcast --rpc-url http://localhost:8545 --unlocked --from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
   ```

`forge` compiles the contract and deploys it from Anvil's first account, which Anvil unlocks for you. The output includes the address:

   ```text
   Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
   ```

`forge` also leaves `cache/` and `out/` folders. Keep them out of git: `printf 'cache\nout\n' >> .gitignore`.

Keep it in a variable, in every terminal you use for Part 2:

   ```sh
   export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3
   ```

2. ### Give the buyer a wallet

Add the x402 packages to the same project:

   ```sh wrap
   npm install @x402/core @x402/evm @x402/fetch @x402/hono hono @hono/node-server @hashspan/x402
   ```

The paying agent gets its own key, in `buyer-wallet.hex`. `buyer-balance.ts` creates it and prints both balances:

   ```ts title="buyer-balance.ts"
   import { type Address, createPublicClient, erc20Abi, formatEther, formatUnits, http } from 'viem';
   import { foundry } from 'viem/chains';
   import { loadAccount } from './wallet.js';

   const TOKEN = process.env.TOKEN as Address;
   const { address } = loadAccount('buyer-wallet.hex');
   const reader = createPublicClient({ chain: foundry, transport: http() });

   const [tusd, eth] = await Promise.all([
     reader.readContract({ address: TOKEN, abi: erc20Abi, functionName: 'balanceOf', args: [address] }),
     reader.getBalance({ address }),
   ]);
   console.log(`address: ${address}`);
   console.log(`TUSD:    ${formatUnits(tusd, 6)}`);
   console.log(`ETH:     ${formatEther(eth)}`);
   ```

**Checkpoint:** `npx tsx buyer-balance.ts` prints a new address with `TUSD: 0` and `ETH: 0`.

3. ### Run a paid API on your machine

`seller.ts` is a small API with one paid route, priced at 1000 units of Test USD, which is 0.001 TUSD (6 decimals, like USDC). The facilitator runs inside it, and settles from Anvil's first account, which pays the gas:

   ```ts title="seller.ts"
   import { serve } from '@hono/node-server';
   import { x402Facilitator } from '@x402/core/facilitator';
   import type { FacilitatorClient } from '@x402/core/server';
   import type { SupportedResponse } from '@x402/core/types';
   import { toFacilitatorEvmSigner } from '@x402/evm';
   import { registerExactEvmScheme } from '@x402/evm/exact/facilitator';
   import { ExactEvmScheme } from '@x402/evm/exact/server';
   import { paymentMiddleware, x402ResourceServer } from '@x402/hono';
   import { Hono } from 'hono';
   import { type Address, createWalletClient, http, publicActions } from 'viem';
   import { foundry } from 'viem/chains';

   const TOKEN = process.env.TOKEN as Address; // the Test USD contract you deployed
   const PAY_TO = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC'; // Anvil's third account

   // The facilitator settles payments from Anvil's first account, which pays the gas.
   const settler = createWalletClient({
     account: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
     chain: foundry,
     transport: http(),
     pollingInterval: 250,
   }).extend(publicActions);
   // viem's client types are wider than the ones x402 expects; the cast bridges them.
   type SignerInput = Parameters<typeof toFacilitatorEvmSigner>[0];
   const signer = toFacilitatorEvmSigner({ ...settler, address: settler.account.address } as unknown as SignerInput);
   const facilitator = new x402Facilitator();
   registerExactEvmScheme(facilitator, { signer, networks: 'eip155:31337' });
   const facilitatorClient: FacilitatorClient = {
     verify: (payload, requirements) => facilitator.verify(payload, requirements),
     settle: (payload, requirements) => facilitator.settle(payload, requirements),
     getSupported: async () => facilitator.getSupported() as SupportedResponse,
   };

   const server = new x402ResourceServer(facilitatorClient).register('eip155:31337', new ExactEvmScheme());
   const price = { asset: TOKEN, amount: '1000', extra: { name: 'Test USD', version: '1' } }; // 0.001 TUSD

   const app = new Hono();
   app.use(
     paymentMiddleware(
       { 'GET /weather': { accepts: [{ scheme: 'exact', price, network: 'eip155:31337', payTo: PAY_TO }] } },
       server,
     ),
   );
   app.get('/weather', (c) => c.json({ city: 'Lisbon', forecast: 'sunny, 24 °C' }));

   serve({ fetch: app.fetch, port: 4021 });
   console.log('Paid API on http://localhost:4021/weather');
   ```

Start it in a new terminal:

   ```sh
   cd first-paying-agent
   export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3
   npx tsx seller.ts
   ```

`eip155:31337` is how x402 v2 names a chain: the chain id with a prefix.

**Checkpoint:** `curl -i localhost:4021/weather` answers `HTTP/1.1 402 Payment Required`, with a `payment-required` header. That header is base64 JSON. To decode it:

   ```sh wrap
   curl -si localhost:4021/weather | grep -i '^payment-required' | cut -d' ' -f2 | tr -d '\r' | base64 -d
   ```

Its offer reads:

   ```json
   {
     "scheme": "exact",
     "network": "eip155:31337",
     "amount": "1000",
     "asset": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
     "payTo": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
     "maxTimeoutSeconds": 300,
     "extra": { "name": "Test USD", "version": "1" }
   }
   ```

4. ### Let the agent pay before it has tokens

The buyer is a second agent with one tool, `get_weather`. The tool pays through `fetchWithPayment`, which handles the 402, the signature and the retry:

   ```ts title="buyer.ts"
   import { provider } from './telemetry.js';
   import { withHashspan } from '@hashspan/x402';
   import { x402Client } from '@x402/core/client';
   import { registerExactEvmScheme } from '@x402/evm/exact/client';
   import { wrapFetchWithPayment } from '@x402/fetch';
   import { stepCountIs, tool, ToolLoopAgent } from 'ai';
   import { type Address, createPublicClient, http } from 'viem';
   import { foundry } from 'viem/chains';
   import { z } from 'zod';
   import { scriptedModel } from './model.js';
   import { loadAccount } from './wallet.js';

   const TOKEN = process.env.TOKEN as Address;

   const client = new x402Client();
   // Before any hooks of your own, so none of them can hide a payment's outcome from hashspan.
   const hashspan = withHashspan(client, {
     agent: { name: 'first-paying-agent' },
     reader: createPublicClient({ chain: foundry, transport: http(), pollingInterval: 250 }),
   });
   registerExactEvmScheme(client, { signer: loadAccount('buyer-wallet.hex') });
   // Allow Test USD, at most 0.01 (10000 units) per payment.
   client.setSpendControls({
     allowedAssets: [{ network: 'eip155:31337', asset: TOKEN, maxAmountPerPayment: '10000' }],
   });
   // Pay in Test USD only: drop every other asset an API offers.
   client.registerPolicy((_version, offers) =>
     offers.filter((offer) => offer.asset.toLowerCase() === TOKEN.toLowerCase()),
   );
   const fetchWithPayment = wrapFetchWithPayment(fetch, client);

   const getWeather = tool({
     description: 'Get the weather forecast from a paid API (costs 0.001 TUSD).',
     inputSchema: z.object({}),
     execute: async () => {
       const response = await fetchWithPayment('http://localhost:4021/weather');
       if (!response.ok) {
         // A refused payment comes back as a second 402; its header says why.
         const required = response.headers.get('payment-required');
         const reason = required ? JSON.parse(atob(required)).error : '';
         throw new Error(`Paid API answered ${response.status} ${reason}`);
       }
       return response.json();
     },
   });

   const agent = new ToolLoopAgent({
     model: scriptedModel('get_weather', {}),
     telemetry: { functionId: 'first-paying-agent' },
     instructions: 'You answer weather questions with the get_weather tool.',
     tools: { get_weather: getWeather },
     stopWhen: stepCountIs(3),
   });

   const result = await agent.generate({ prompt: "What's the weather in Lisbon?" });
   console.log(result.text);

   await hashspan.flush();
   await provider.shutdown();
   ```

The x402 client has two safety nets here. `setSpendControls` adds Test USD to the assets the client accepts, capped at 10000 units per payment.

A list adds to the defaults, it doesn't replace them: USDC and the other default assets stay allowed, capped at $1 per payment. So `registerPolicy` drops every offer that isn't Test USD. Both refuse before anything is signed.

`withHashspan` comes before any hooks you add yourself, as the [x402 adapter](https://hashspan.dev/docs/adapters/x402#usage) asks: a hook registered earlier could keep it from seeing a payment's outcome. Its `reader` lets it check each settlement on chain.

Run `npx tsx buyer.ts` while the buyer still holds no tokens.

**Checkpoint:**

   ```text wrap
   Tool result: {"type":"error-text","value":"Error: Paid API answered 402 invalid_exact_evm_transaction_simulation_failed"}
   ```

Trace (buyer.ts with 0 TUSD, simplified · durations from the run):

- `invoke_agent scripted-model`: 110 ms
  - `execute_tool get_weather`: 105 ms · error
    - `payment 31337`: 65 ms · no_settlement

The payment span records the offer it signed for, and error.type = no_settlement.

**What just happened:**
The agent signed for 0.001 TUSD, and the facilitator refused to settle it. The `payment 31337` span shows who paid whom, what and how much (`blockchain.payment.amount` `1000`), and that nothing settled.

The payment span doesn't carry the reason. The second 402 does, in the `error` field of its `payment-required` header, and the tool puts it in its error. So the reason shows on the `execute_tool get_weather` span:

   ```text
   invalid_exact_evm_transaction_simulation_failed
   ```

The facilitator tried the transfer first, and it failed for lack of funds.

5. ### Mint test tokens to the buyer

On a public testnet this is a faucet. Here, Test USD lets anyone mint. Use the buyer's address from step 2; 20000000 units are 20 TUSD:

   ```sh wrap
   cast send $TOKEN "mint(address,uint256)" 0xYourBuyerAddress 20000000 --unlocked --from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
   ```

**Checkpoint:** `npx tsx buyer-balance.ts` shows `TUSD: 20` and still `ETH: 0`.

6. ### Pay with tokens and read the trace

Run `npx tsx buyer.ts` again.

**Checkpoint:** the tool returns the forecast, `{"city":"Lisbon","forecast":"sunny, 24 °C"}`, and `buyer-balance.ts` shows `TUSD: 19.999` and still `ETH: 0`.

Trace (buyer.ts with 20 TUSD, simplified · durations from the run):

- `invoke_agent scripted-model`: 312 ms
  - `execute_tool get_weather`: 306 ms
    - `payment 31337`: 286 ms · settled
    - `confirm 31337`: 11 ms

No send span: the agent sent no transaction. The confirm span links back to the payment span.

   | Span | Attribute | Value |
   |---|---|---|
   | `payment 31337` | `blockchain.payment.status` | `settled` |
   | | `blockchain.payment.verified` | `true` |
   | | `blockchain.payment.amount` | `1000` |
   | `confirm 31337` | `blockchain.tx.status` | `success` |
   | | `blockchain.tx.gas.used` | about 86,500 (`86486` in this run) |

**What just happened:**
The agent signed an authorization for exactly 1000 units to `payTo`. The facilitator sent it to the token contract, which moved the funds, and paid the gas: the buyer still holds 0 ETH.

The payment span carries the settlement's transaction hash. With a `reader`, hashspan fetched that receipt and checked it carries this payment: that is `verified = true`. The confirm span holds the receipt, and its [span link](https://hashspan.dev/learn/otel-span-links) points back to the payment.

### What if the price is over your cap, or the asset is wrong?

Set the cap below the price, `maxAmountPerPayment: '500'`, and run again. The x402 client refuses before it signs. Nothing is paid and no payment span is recorded.

Your tool throws, so `execute_tool get_weather` carries the error:

```text wrap
Failed to create payment payload: All payment requirements were rejected by spendControls.allowedAssets maxAmountPerPayment.
```

The policy works the same way. Make it keep another asset, such as Base Sepolia's USDC address, and the Test USD offer is dropped:

```text wrap
Failed to create payment payload: All payment requirements were filtered out by policies for x402 version: 2
```

Both are by design: a refused payment never left your process. The [x402 adapter](https://hashspan.dev/docs/adapters/x402#known-limits) lists what is and isn't traced.

## What do the failures look like in the trace?

| Symptom | What the trace shows | Fix |
|---|---|---|
| Tool fails with "exceeds the balance" | `send` with `error.type` `InsufficientFundsError`, no `confirm` | fund the address; see [insufficient funds](https://hashspan.dev/errors/insufficient-funds) |
| x402 request ends in a second 402 | `payment` with `error.type` `no_settlement`; the reason on the tool span | the `error` from the `payment-required` header; often a token balance below the price |
| Tool fails with a `spendControls` or policy message | no payment span; the tool span has the error | raise `maxAmountPerPayment`, or check the asset your policy keeps |
| Two parallel payments, one fails with "nonce too low" | a `send` with `error.type` `NonceTooLowError` | see [nonce too low](https://hashspan.dev/errors/nonce-too-low) and the [AI SDK recipe](https://hashspan.dev/docs/recipes/ai-sdk) |
| A retry paid twice | two `settled` payment spans under one tool span | see [payment, request and task outcomes](https://hashspan.dev/docs/adapters/x402#payment-request-and-task-outcomes) |
| No spans in Jaeger | nothing for `first-paying-agent` | import `telemetry.ts` first, and keep the `flush()` and `shutdown()` lines |
| `anvil` fails to start | port 8545 already in use | stop the other chain, or run `anvil --port 8546` and set the RPC URL in `http()` |

Paste any error message into [the error decoder](https://hashspan.dev/errors/decode) to see what it means and how hashspan records it. To look up an attribute name, use the [OpenTelemetry attribute lookup](https://hashspan.dev/learn/otel-attributes).

## What did you build?

- An agent with a wallet whose key never enters your code, your repository or a prompt.
- Two ways to pay: sending a transaction yourself (Part 1) and signing an x402 payment that someone else settles (Part 2).
- Two spending caps: one in your tool, one in the x402 client.
- A trace for every payment, failed or not, under the tool call that made it.

On a public network, the paid API you call runs its own facilitator or uses a hosted one, and the token is a real one such as USDC. The [example's README](https://github.com/selimaytac/hashspan/tree/main/examples/x402-agent#base-sepolia-optional-not-run-in-ci) lists what changes for the Base Sepolia testnet.

The setup on your side is two packages: [`@hashspan/viem`](https://hashspan.dev/docs/adapters/viem) and [`@hashspan/x402`](https://hashspan.dev/docs/adapters/x402). hashspan is open source on [GitHub](https://github.com/selimaytac/hashspan).

## Want the complete, CI-tested version?

[examples/x402-agent](https://github.com/selimaytac/hashspan/tree/main/examples/x402-agent) is this tutorial as one agent. Its `research-agent` tips an author in ETH (`tip_author`, Part 1's send and confirm) and buys a report over x402 (`buy_report`, Part 2's payment and confirm), with the same in-process facilitator and Test USD. Its test checks that trace in CI: a `send 31337` and a linked `confirm 31337` under one tool, and a settled, verified `payment 31337` with a `confirm 31337` linked to it under the other.

The example needs Node.js 24 (`nvm use` reads it from `.nvmrc`; install [nvm](https://github.com/nvm-sh/nvm) first) and Docker for `make lab-up`. Clone [selimaytac/hashspan](https://github.com/selimaytac/hashspan) and, from the repository root, after `nvm use && corepack enable pnpm && pnpm install`:

```sh
make lab-up
make anvil
pnpm build
pnpm --filter @hashspan/example-x402-agent start
```

`make lab-up` starts Jaeger on [localhost:16686](http://localhost:16686), and `make anvil` runs the chain, so give it its own terminal. Then open the service `research-agent` in Jaeger.

## FAQ

### Do I need ETH to pay with x402?

Not on the paying side. With the exact scheme and an EIP-3009 token such as USDC, the agent only signs an authorization. The facilitator sends the transaction and pays its gas. In this tutorial the paying wallet holds 0 ETH from start to finish.

### Can I try x402 without a testnet or a faucet?

Yes. This tutorial runs the whole flow on Anvil, a local chain: a test token, a paid API, a facilitator in the API's process and the paying agent. Nothing leaves your machine and no account is needed.

### How do I stop an agent from overspending?

Cap it in two places. In your tool, check the amount before you pay. In the x402 client, setSpendControls refuses a pricier payment before anything is signed: a USD cap for default assets such as USDC ($1 by default in @x402/core 2.28), or a per-asset cap in units for a token you add. Listing a token adds it to the defaults; to pay in one asset only, add a policy with registerPolicy that drops the other offers.

### Where is the USDC contract on Base Sepolia?

0x036CbD53842c5426634e7929541eC2318f3dCF7e. It has 6 decimals, so 1000 units is $0.001, and its EIP-712 name and version are USDC and 2.

### Can I use LangChain or the OpenAI Agents SDK instead of the AI SDK?

Yes. The payment spans nest under whatever span is active when the tool pays. The AI SDK opens an execute_tool span for you; with other frameworks, see integrations for the span to open yourself.

## Next

- Add hashspan to an agent you already have: [Quickstart](https://hashspan.dev/docs/quickstart).
- Nonce handling, background confirmation and naming the agent: [AI SDK recipe](https://hashspan.dev/docs/recipes/ai-sdk).
- Let a coding agent do the setup: [Build with AI](https://hashspan.dev/docs/build-with-ai).
- What each viem action records: [viem OpenTelemetry instrumentation](https://hashspan.dev/docs/viem-opentelemetry).
