What you will build
A Pydantic AI agent that pays for the tools it calls, settled in USDC on Base, while keeping the typed-result contract Pydantic AI is built around. The agent loop does not change. You add one tool that calls a small local proxy, parse the result into a model, and return it typed. To the agent it is an ordinary validated tool result; underneath, a paid call settled through its wallet.
The honest constraint first: the x402 payment client ships for Node, and Pydantic AI is Python. So the shipped path is a tiny Node proxy that holds the wallet and answers 402 Payment Required, with your tool calling it over localhost. This is the same pattern the CrewAI and LlamaIndex guides use. The Pydantic AI integration page is the broader reference, and the payment API product page covers the wider surface.
Why a Node proxy
A sentence on why the proxy is the right move, not a workaround. The package that answers a 402 and settles in USDC, @blockchain0x/x402, is Node-only today. Inventing a Python import that does not exist would just produce code that fails to install. The proxy is a real Node process doing the real x402 work, reachable from Python over a localhost call your agent already makes. It keeps every payment identifier on the verified surface and costs one extra process per wallet. When a Python x402 client ships, this collapses to a direct call; until then, the proxy is the path that works.
Prerequisites
- A Blockchain0x account with one agent created, so it has a wallet.
- A
sk_test_API key for this walkthrough. - Node 18+ for the proxy (the SDK targets
>=18) and Python 3.9+ for Pydantic AI.
export B0X_API_KEY=sk_test_... # sk_test_ -> Base SepoliaRun the x402 proxy
The only Node you write. It builds a fetch that answers 402s and exposes one route your tool can post a URL to.
// pay-proxy.mjs -> npm i @blockchain0x/node @blockchain0x/x402 && node pay-proxy.mjs
import http from "node:http";
import { createClient } from "@blockchain0x/node";
import { createX402Client } from "@blockchain0x/x402/client";
const sdk = createClient({ apiKey: process.env.B0X_API_KEY }); // sk_test_ pins it to Base Sepolia
const fetchWithPay = createX402Client({ sdk });
http
.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const { url } = JSON.parse(Buffer.concat(chunks).toString() || "{}");
try {
const upstream = await fetchWithPay(url); // settles the 402 in USDC, retries
res.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "text/plain" });
res.end(await upstream.text());
} catch (err) {
res.writeHead(502).end(String(err));
}
})
.listen(8787, () => console.log("pay-proxy on :8787"));Start it next to your agent: node pay-proxy.mjs. One proxy per agent wallet, each on its own port with its own B0X_API_KEY.
Register a typed paid tool
In Pydantic AI, a tool is a function registered on the agent. Write one that calls the proxy and register it. No Blockchain0x code lives in Python.
import requests
from pydantic_ai import Agent
agent = Agent("openai:gpt-4o", system_prompt="You are an equity research analyst.")
@agent.tool_plain
def get_quote_realtime(ticker: str) -> str:
"""Fetch a real-time quote for a stock ticker."""
upstream = f"https://quotes.example.com/v1/quote?ticker={ticker}"
res = requests.post("http://127.0.0.1:8787", json={"url": upstream}, timeout=30)
return res.text if res.status_code == 200 else f"Lookup failed: {res.status_code}"The agent calls get_quote_realtime like any other tool. The proxy settles the upstream 402 in USDC, and the result comes back. The agent never sees the payment.
Keep the result typed
Pydantic AI's whole point is validated, typed data, so do not throw that away at the payment boundary. Parse the proxy's response into a Pydantic model and return the model, not a raw string.
from pydantic import BaseModel
class Quote(BaseModel):
ticker: str
price: float
@agent.tool_plain
def get_quote(ticker: str) -> Quote:
"""Fetch a typed real-time quote."""
upstream = f"https://quotes.example.com/v1/quote?ticker={ticker}"
res = requests.post("http://127.0.0.1:8787", json={"url": upstream}, timeout=30)
res.raise_for_status()
return Quote.model_validate_json(res.text)Now the agent receives a Quote, validated, with the same guarantees as any other Pydantic AI tool. Payment is an implementation detail inside the function, and the type contract the agent reasons about is unchanged. If the paid call fails, raise_for_status surfaces it as an exception the agent run can handle, rather than feeding the model a malformed string. Keeping the boundary typed is what makes the paid tool feel native to Pydantic AI rather than bolted on.
Validate what you paid for
There is a quieter benefit to keeping the boundary typed, and it is specific to paid tools. When an agent pays for data, you want to know you got what you paid for, not just that a request returned. A 200 with a malformed or empty body is a payment you made for nothing, and a raw-string tool would hand that straight to the model as if it were a real answer.
The Pydantic model is your check. Quote.model_validate_json fails loudly if the paid response does not match the shape you expected, so a provider that quietly changed its format, or returned an error page with a 200, raises instead of polluting the agent's context. You can decide what to do with that failure: retry once against a different provider, return a clear error the planner can act on, or flag it for review. The point is that you paid for a Quote, so you assert you received a Quote, and the type system turns "I hope the paid data is good" into "the paid data is validated or the call fails". For an agent spending real money per call, that guarantee is worth more than it looks.
Set spend limits and test
Set the limit in the dashboard, where the wallet enforces it before any payment, and read it back to confirm:
import os, requests
res = requests.get(
f"https://api.blockchain0x.com/v1/agents/{os.environ['AGENT_ID']}/spend-permissions",
headers={"Authorization": f"Bearer {os.environ['B0X_API_KEY']}"},
timeout=30,
)
permissions = res.json() # per_tx_wei, allowance_wei, period_seconds, ...Run the loop on Base Sepolia first with a sk_test_ key: fund the agent's wallet with test USDC from a public faucet, start the proxy, and watch a paid tool call settle. Exercise a refused payment by setting a tight per_tx_wei and confirming an over-limit call surfaces as an error the agent handles. When both paths behave, swap to a sk_live_ key for Base mainnet.
Common pitfalls
Three traps.
Exposing the proxy beyond localhost. It holds a wallet key and pays whatever URL it is handed. Bind it to 127.0.0.1 and treat it like the credential it is.
Dropping the type at the payment boundary. Returning a raw string from a paid tool throws away Pydantic AI's main benefit. Parse into a model and return that.
Putting the budget in the system prompt. A budget in the prompt is a suggestion a crafted input can talk past. The dashboard spend limit is the real boundary, enforced by the wallet before any payment, where the agent cannot reach it.
What to ship today
Start one pay-proxy.mjs with a sk_test_ key, register one typed Pydantic AI tool that calls it, and watch a paid call settle on Base Sepolia. Set a dashboard spend limit before you switch to a sk_live_ key. The CrewAI and LlamaIndex versions of this pattern are at how-to-add-payments-to-crewai-agent and how-to-add-payments-to-llamaindex-agent. Pricing is on the pricing page.