Skip to content
Star

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

View as MarkdownOpen in ClaudeOpen in ChatGPT

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 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 in the hashspan repository. Run it instead if you’d rather clone than type.

Know wallets and gas already? Jump to Part 2. 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).

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.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?

Section titled “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. 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:

    Terminal window
    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:

    Terminal window
    anvil

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

    Terminal window
    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 opens Jaeger.

  2. Terminal window
    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 shows one in three minutes.

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

    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. wallet.ts loads a key from a file, or makes a new one there the first time:

    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:

    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:

    address: 0xfA818720ae6029D0F76C84A377cA6cD610d40dDb
    balance: 0 ETH
  4. 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.

    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. With OpenAI, for example, npm install @ai-sdk/openai, set OPENAI_API_KEY, and:

    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.

    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:

    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.

    agent.ts with 0 ETHsimplified · durations from the run
    SpanTime and result
    invoke_agent scripted-model53 ms53 ms
    inside invoke_agent scripted-model: execute_tool pay_vendor47 ms · error47 ms · error
    inside execute_tool pay_vendor: send 3133734 ms · InsufficientFundsError34 ms · InsufficientFundsError
    The send span carries error.type = InsufficientFundsError, and the tool span is marked as an error.
  5. On a testnet, funds come from a faucet. On Anvil you set the balance yourself. Use your address from step 3:

    Terminal window
    cast rpc anvil_setBalance 0xYourAddress 1000000000000000000

    It prints null: the call returns nothing. The amount is in wei: 1018 wei is 1 ETH.

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

  6. 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.

    agent.ts with 1 ETHsimplified · durations from the run
    SpanTime and result
    invoke_agent scripted-model311 ms311 ms
    inside invoke_agent scripted-model: execute_tool pay_vendor301 ms301 ms
    inside execute_tool pay_vendor: send 3133730 ms30 ms
    inside execute_tool pay_vendor: confirm 31337269 ms · success269 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)

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 shows how that looks in a trace.

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

Section titled “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.

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.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.
  1. A token is a contract that keeps a balance per address. The x402 payment here uses 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.

    Terminal window
    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:

    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:

    Terminal window
    export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3
  2. Add the x402 packages to the same project:

    Terminal window
    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:

    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. 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:

    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:

    Terminal window
    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:

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

    Its offer reads:

    {
    "scheme": "exact",
    "network": "eip155:31337",
    "amount": "1000",
    "asset": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
    "payTo": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
    "maxTimeoutSeconds": 300,
    "extra": { "name": "Test USD", "version": "1" }
    }
  4. 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:

    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 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:

    Tool result: {"type":"error-text","value":"Error: Paid API answered 402 invalid_exact_evm_transaction_simulation_failed"}
    buyer.ts with 0 TUSDsimplified · durations from the run
    SpanTime and result
    invoke_agent scripted-model110 ms110 ms
    inside invoke_agent scripted-model: execute_tool get_weather105 ms · error105 ms · error
    inside execute_tool get_weather: payment 3133765 ms · no_settlement65 ms · no_settlement
    The payment span records the offer it signed for, and error.type = no_settlement.
  5. 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:

    Terminal window
    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. 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.

    buyer.ts with 20 TUSDsimplified · durations from the run
    SpanTime and result
    invoke_agent scripted-model312 ms312 ms
    inside invoke_agent scripted-model: execute_tool get_weather306 ms306 ms
    inside execute_tool get_weather: payment 31337286 ms · settled286 ms · settled
    inside execute_tool get_weather: confirm 3133711 ms11 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 if the price is over your cap, or the asset is wrong?

Section titled “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:

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:

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 lists what is and isn’t traced.

What do the failures look like in the trace?

Section titled “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
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 and the AI SDK recipe
A retry paid twice two settled payment spans under one tool span see 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 to see what it means and how hashspan records it. To look up an attribute name, use the OpenTelemetry attribute lookup.

  • 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 lists what changes for the Base Sepolia testnet.

The setup on your side is two packages: @hashspan/viem and @hashspan/x402. hashspan is open source on GitHub.

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 first) and Docker for make lab-up. Clone selimaytac/hashspan and, from the repository root, after nvm use && corepack enable pnpm && pnpm install:

Terminal window
make lab-up
make anvil
pnpm build
pnpm --filter @hashspan/example-x402-agent start

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

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.