x402 tutorial: your first AI agent that pays (and the trace that shows it)
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).
By the end you can:
- Create a wallet and keep its key out of your repository and your prompts.
- Fund it, and tell ETH, which pays for gas, from a token, which pays for things.
- Give an agent a tool that pays, with a cap on what it can spend.
- 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.
-
Start a local chain and Jaeger
Section titled “Start a local chain and Jaeger”Install Foundry, which ships Anvil and the
castcommand line tool. The installer puts it in~/.foundry/bin. Add that folder to yourPATHin your shell profile (~/.zshrchere,~/.bashrcon bash), so every terminal finds it:Terminal window curl -L https://foundry.paradigm.xyz | bashecho 'export PATH="$PATH:$HOME/.foundry/bin"' >> ~/.zshrcexport PATH="$PATH:$HOME/.foundry/bin"foundryupThen start the chain in one terminal. It listens on localhost:8545:
Terminal window anvilIn a second terminal, start Jaeger, a trace viewer:
Terminal window docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:2.21.0Checkpoint: in a third terminal,
cast block-numberprints0, and localhost:16686 opens Jaeger. -
Create the project
Section titled “Create the project”Terminal window mkdir first-paying-agent && cd first-paying-agentnpm init -y && npm pkg set type=modulenpm 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 tsxprintf 'node_modules\n*.hex\n' > .gitignoreThe 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:4318spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())],});provider.register();// The AI SDK's own spans (invoke_agent, execute_tool) use the same provider.registerTelemetry(new OpenTelemetry()); -
Give the agent a wallet
Section titled “Give the agent a wallet”wallet.tsloads 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.tsprints 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.tsprints a new address andbalance: 0 ETH. Yours differs from mine:address: 0xfA818720ae6029D0F76C84A377cA6cD610d40dDbbalance: 0 ETH -
Let the agent pay with an empty wallet
Section titled “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.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 accountconst 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 anexecute_toolspan, 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.tsbelow holds it.To use a real LLM instead, swap the
modelline for an AI SDK provider. With OpenAI, for example,npm install @ai-sdk/openai, setOPENAI_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 pagehashspan’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-agentand open the trace. The drawings on this page are simplified: Jaeger also shows astep 1span with achat scripted-modelspan in it, andexecute_toolsits understep 1, one level deeper.agent.ts with 0 ETHsimplified · durations from the runSpanTime and resultinvoke_agent scripted-model53 ms53 msinside invoke_agent scripted-model: execute_tool pay_vendor47 ms · error47 ms · errorinside execute_tool pay_vendor: send 3133734 ms · InsufficientFundsError34 ms · InsufficientFundsErrorThe send span carries error.type = InsufficientFundsError, and the tool span is marked as an error. -
Fund the wallet
Section titled “Fund the wallet”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 1000000000000000000It prints
null: the call returns nothing. The amount is in wei: 1018 wei is 1 ETH.Checkpoint:
npx tsx balance.tsprintsbalance: 1 ETH. -
Pay again and read the trace
Section titled “Pay again and read the trace”Run
npx tsx agent.tsagain.Checkpoint: the tool returns a hash and
"status":"success", andnpx tsx balance.tsprintsbalance: 0.994958 ETH.agent.ts with 1 ETHsimplified · durations from the runSpanTime and resultinvoke_agent scripted-model311 ms311 msinside invoke_agent scripted-model: execute_tool pay_vendor301 ms301 msinside execute_tool pay_vendor: send 3133730 ms30 msinside execute_tool pay_vendor: confirm 31337269 ms · success269 ms · successsend ends when the hash comes back. confirm ends when the receipt does, and links back to its send span. The
confirm 31337span holds the receipt:Attribute Value blockchain.tx.statussuccessblockchain.tx.gas.used21000blockchain.tx.effective_gas_price2000000000(2 gwei)blockchain.tx.fee42000000000000(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.
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.
-
Deploy a test token
Section titled “Deploy a test token”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 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266forgecompiles the contract and deploys it from Anvil’s first account, which Anvil unlocks for you. The output includes the address:Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3forgealso leavescache/andout/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 -
Give the buyer a wallet
Section titled “Give the buyer a wallet”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/x402The paying agent gets its own key, in
buyer-wallet.hex.buyer-balance.tscreates 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.tsprints a new address withTUSD: 0andETH: 0. -
Run a paid API on your machine
Section titled “Run a paid API on your machine”seller.tsis 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 deployedconst 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 TUSDconst 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-agentexport TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3npx tsx seller.tseip155:31337is how x402 v2 names a chain: the chain id with a prefix.Checkpoint:
curl -i localhost:4021/weatheranswersHTTP/1.1 402 Payment Required, with apayment-requiredheader. 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 -dIts offer reads:
{"scheme": "exact","network": "eip155:31337","amount": "1000","asset": "0x5FbDB2315678afecb367f032d93F642f64180aa3","payTo": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC","maxTimeoutSeconds": 300,"extra": { "name": "Test USD", "version": "1" }} -
Let the agent pay before it has tokens
Section titled “Let the agent pay before it has tokens”The buyer is a second agent with one tool,
get_weather. The tool pays throughfetchWithPayment, 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.
setSpendControlsadds 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
registerPolicydrops every offer that isn’t Test USD. Both refuse before anything is signed.withHashspancomes before any hooks you add yourself, as the x402 adapter asks: a hook registered earlier could keep it from seeing a payment’s outcome. Itsreaderlets it check each settlement on chain.Run
npx tsx buyer.tswhile 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 runSpanTime and resultinvoke_agent scripted-model110 ms110 msinside invoke_agent scripted-model: execute_tool get_weather105 ms · error105 ms · errorinside execute_tool get_weather: payment 3133765 ms · no_settlement65 ms · no_settlementThe payment span records the offer it signed for, and error.type = no_settlement. -
Mint test tokens to the buyer
Section titled “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:
Terminal window cast send $TOKEN "mint(address,uint256)" 0xYourBuyerAddress 20000000 --unlocked --from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266Checkpoint:
npx tsx buyer-balance.tsshowsTUSD: 20and stillETH: 0. -
Pay with tokens and read the trace
Section titled “Pay with tokens and read the trace”Run
npx tsx buyer.tsagain.Checkpoint: the tool returns the forecast,
{"city":"Lisbon","forecast":"sunny, 24 °C"}, andbuyer-balance.tsshowsTUSD: 19.999and stillETH: 0.buyer.ts with 20 TUSDsimplified · durations from the runSpanTime and resultinvoke_agent scripted-model312 ms312 msinside invoke_agent scripted-model: execute_tool get_weather306 ms306 msinside execute_tool get_weather: payment 31337286 ms · settled286 ms · settledinside execute_tool get_weather: confirm 3133711 ms11 msNo send span: the agent sent no transaction. The confirm span links back to the payment span. Span Attribute Value payment 31337blockchain.payment.statussettledblockchain.payment.verifiedtrueblockchain.payment.amount1000confirm 31337blockchain.tx.statussuccessblockchain.tx.gas.usedabout 86,500 ( 86486in 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: 2Both 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.
What did you build?
Section titled “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 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.
Want the complete, CI-tested version?
Section titled “Want the complete, CI-tested version?”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:
make lab-upmake anvilpnpm buildpnpm --filter @hashspan/example-x402-agent startmake 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.
- Add hashspan to an agent you already have: Quickstart.
- Nonce handling, background confirmation and naming the agent: AI SDK recipe.
- Let a coding agent do the setup: Build with AI.
- What each viem action records: viem OpenTelemetry instrumentation.