x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
Target audience: developers building autonomous AI agents that need to charge or pay for fine‑grained services without reinventing a billing layer.
1. Why look at 402 again?
The HTTP status code 402 Payment Required was reserved in the original spec but never got a widely adopted implementation. Recent work (the x402 draft) revives it as a lightweight, transport‑agnostic way to request payment directly inside an HTTP exchange. The idea is simple:
- A server responds with
402and includes payment instructions in a well‑known header (Pay). - The client (here, an AI agent) fulfills the instruction, then retries the request with a proof‑of‑payment header (
Paid). - If the proof validates, the server returns the actual resource (
200 OK).
Because the negotiation lives in the protocol, no extra SDK or custom RPC layer is required—any HTTP client can participate, and any server can enforce payment with just a few lines of middleware.
2. The x402 flow in detail
| Step | Actor | Action | Header(s) |
|---|---|---|---|
| 1 | Client |
GET /service (no payment) |
— |
| 2 | Server | Detects missing/invalid payment → 402 Payment Required
|
Pay: <scheme>://<payload> |
| 3 | Client | Executes the payment instruction (e.g., sends USDC on Base) → obtains a transaction receipt or signed proof | — |
| 4 | Client | Retries original request with proof | Paid: <scheme>://<proof> |
| 5 | Server | Validates proof → if OK, returns requested data (200) |
— |
| 6 | Server | If proof invalid or missing → another 402 (possibly with updated amount) |
Pay |
The Pay and Paid headers are defined as URIs; the scheme indicates the payment method (e.g., paybase: for USDC on Base). The payload can be a JSON‑encoded object containing amount, destination address, nonce, and an optional expiry.
Because the headers are plain text, they survive proxies, caches, and middleware unchanged—making the mechanism truly HTTP‑native.
3. Minimal server implementation (Node.js + Express)
Below is a self‑contained example that charges 0.01 USDC per call. It does not try to be a production‑grade payment processor; it simply demonstrates where the verification hook would go.
// server.js
import express from 'express';
import { Base } from '@thirdweb-dev/chains';
import { ethers } from 'ethers';
const app = express();
const PORT = 3000;
// ---- CONFIG ----
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // USDC on Base (mainnet)
const RECIPIENT = '0xYourAgentWalletAddress'; // where payments go
const AMOUNT_USDC = 0.01; // dollars per request
const DECIMALS = 6; // USDC has 6 decimals
const PROVIDER_URL = 'https://base.mainnet.rpc.typo.xyz'; // public RPC (replace with your own)
const provider = new ethers.JsonRpcProvider(PROVIDER_URL);
const usdcAbi = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)"
];
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, provider);
// Helper: format amount in wei (USDC's smallest unit)
function toMicro(amount) {
return ethers.parseUnits(String(amount), DECIMALS);
}
// Middleware that enforces x402
function requirePayment(req, res, next) {
const paid = req.headers['paid'];
if (!paid) {
// No proof → ask for payment
const payUri = `paybase:${JSON.stringify({
token: USDC_ADDRESS,
recipient: RECIPIENT,
amount: AMOUNT_USDC,
nonce: Math.floor(Date.now() / 1000), // per‑second nonce to avoid replays
expiry: Math.floor(Date.now() / 1000) + 300 // 5 min window
})}`;
return res.status(402).set('Pay', payUri).send('Payment required');
}
// Very basic validation: check that the URI matches our expected format
let payload;
try {
payload = JSON.parse(paid.slice('paybase:'.length));
} catch (_) {
return res.status(402).set('Pay', `paybase:${JSON.stringify({error:'malformed'})}`).send('Invalid payment proof');
}
// Verify nonce hasn't been used recently (in‑memory set for demo)
if (requirePayment.usedNonces.has(payload.nonce)) {
return res.status(402).set('Pay', `paybase:${JSON.stringify({error:'replay'})}`).send('Nonce already used');
}
requirePayment.usedNonces.add(payload.nonce);
// Optional: prune old nonces every few minutes (omitted for brevity)
// Verify on‑chain: check that recipient received at least the amount
// NOTE: In a real service you would listen to Transfer events or use a trusted indexer.
usdc.balanceOf(RECIPIENT).then(bal => {
const expected = toMicro(AMOUNT_USDC);
if (bal < expected) {
// Funds not yet seen – ask client to retry
return res.status(402).set('Pay', `paybase:${JSON.stringify({error:'insufficient', needed:AMOUNT_USDC})}`).send('Awaiting funds');
}
// Payment looks good – proceed
next();
}).catch(err => {
console.error(err);
res.status(500).send('Internal error');
});
}
// Initialize nonce tracker
requirePayment.usedNonces = new Set();
// Example protected endpoint
app.get('/agent/service', requirePayment, (req, res) => {
// In practice this would be your AI inference, data lookup, etc.
res.json({ result: `Hello from your paid agent! Timestamp: ${Date.now()}` });
});
app.listen(PORT, () => console.log(`x402 demo listening on :${PORT}`));
What the code does
-
402 response – When a request lacks a
Paidheader, the middleware builds apaybase:URI containing token, recipient, amount, a nonce, and an expiry. -
Client retry – The agent must read that header, execute the USDC transfer, then resend with
Paid: paybase:<same‑payload>. - Server‑side verification – The demo checks a simple in‑memory nonce set to prevent replays and reads the USDC balance of the recipient to confirm funds arrived. In production you’d replace the balance check with an event‑watcher or a trusted roll‑up indexer for lower latency and stronger guarantees.
4. Agent‑side client (Python)
The following snippet shows how an autonomous agent would interact with the endpoint above. It uses web3.py to send the USDC transfer and requests for the HTTP calls.
python
# agent_client.py
import os, time, json, requests
from web3 import Web3
from eth_account import Account
# ---- CONFIG ----
RPC_URL = "https://base.mainnet.rpc.typo.xyz"
USDC_ADDRESS = Web3.to_checksum_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")
RECIPIENT = Web3.to_checksum_address("0xYourAgentWalletAddress")
PRIVATE_KEY = os.getenv("AGENT_PRIVATE_KEY") # fund this address with a little USDC
AMOUNT_USDC = 0.01
DECIMALS = 6
SERVICE_URL = "http://localhost:3000/agent/service"
w3 = Web3(Web3.HTTPProvider(RPC_URL))
account = Account.from_key(PRIVATE_KEY)
usdc_abi = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)"
]
usdc = w3.eth.contract(address=USDC_ADDRESS, abi=usdc_abi)
def build_pay_uri(nonce):
payload = {
"token": USDC_ADDRESS,
"recipient": RECIPIENT,
"amount": AMOUNT_USDC,
"nonce": nonce,
"expiry": int(time.time()) + 300
}
return f"paybase:{json.dumps(payload)}"
def send_usdc(to, amount_usdc):
amount = Web3.to_wei(amount_usdc, 'mwei') # USDC has 6 decimals → 1 USDC = 1,000,000 wei
tx = usdc.functions.transfer(to, amount).build_transaction({
"chainId": w3.eth.chain_id,
"gas": 100_000,
"maxFeePerGas": w3.to_wei(2, 'gwei'),
"maxPriorityFeePer
Top comments (0)