x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
Target audience: developers building autonomous AI agents that need to consume paid services over HTTP.
1. Why a new status code?
HTTP already defines a family of client‑error responses (4xx) and server‑error responses (5xx). The missing piece for “pay‑to‑access” semantics is 402 Payment Required – a code that has been reserved since HTTP/1.0 but never standardized for a concrete payment mechanism.
The x402 proposal (see IETF draft draft‑ietf‑httpapi‑x402‑00) treats 402 as a signal that the server is willing to serve the request if the client includes a valid payment proof in a subsequent request. The flow mirrors the classic 401 WWW‑Authenticate challenge, but instead of a token the client supplies a cryptographic receipt of a blockchain transaction.
For AI agents, this model offers a few practical advantages:
| Property | How x402 helps |
|---|---|
| Statelessness | No session cookies or OAuth flows; each request carries its own proof. |
| Atomicity | Payment is verified on‑chain before the server processes the payload, eliminating “pay‑then‑fail” races. |
| Granular pricing | Agents can pay per‑call, per‑token, or per‑byte without pre‑buying quotas. |
| Interoperability | Works over plain HTTP/HTTPS; no new transport protocol needed. |
The trade‑off is that agents must manage a wallet, handle transaction latency, and absorb the gas cost of the underlying chain. In practice, these costs are often smaller than the subscription fees they replace, but they are not zero.
2. The protocol in a nutshell
-
Client → Server (GET/POST …)
- No payment proof attached.
-
Server → Client (response)
- Status:
402 Payment Required - Header:
X402-Payment-Requirements: <json> - Body: optional human‑readable explanation.
- Status:
- Client parses the JSON, builds a transaction that pays the amount to the address specified, signs it with its wallet, and waits for inclusion.
-
Client → Server (retry)
- Header:
X402-Payment: <tx_hash>(or a signed receipt) - If the server validates the transaction (confirmed, correct amount, correct receiver, nonce/replay protection), it processes the original request and returns
200 OK. - If validation fails, the server returns another
402with an updated challenge (e.g., a higher nonce).
- Header:
The JSON challenge typically contains:
{
"scheme": "eip155:8453:usdc", // chain ID + token contract (USDC on Base)
"network": "base",
"amount": "0.000005", // token amount in base units (USDC has 6 decimals)
"payee": "0x1234…abcd", // receiver address
"maxFeePerGas": "0.00002", // optional gas price hint
"nonce": 42 // monotonic counter to prevent replay
}
3. Minimal working example
Below is a self‑contained Node.js** Express server that protects a dummy /summarize endpoint with x402, and a client script that pays using USDC on Base via viem (a lightweight ethers‑compatible library). The code is deliberately stripped down to illustrate the flow; production systems would add proper error handling, TLS, and more robust nonce management.
3.1 Server (server.js)
// server.js
import express from 'express';
import { createPublicClient, http, parseUnits } from 'viem';
import { base } from 'viem/chains';
import crypto from 'crypto';
const app = express();
app.use(express.json());
// USDC on Base (address from https://docs.base.org/)
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const PAYEE = '0xYourReceiverAddressHere'; // replace with your wallet
const CHAIN = base;
// In‑memory nonce store (in production use Redis or DB)
const nonces = new Map();
function generateChallenge(clientIp) {
const nonce = (nonces.get(clientIp) ?? 0) + 1;
nonces.set(clientIp, nonce);
return {
scheme: `eip155:${CHAIN.id}:usdc`,
network: CHAIN.name,
amount: parseUnits('0.01', 6).toString(), // $0.01 USDC (6 decimals)
payee: PAYEE,
maxFeePerGas: parseUnits('0.00002', 18).toString(),
nonce,
};
}
// Verify that a tx paid the expected amount to PAYEE
async function verifyPayment(txHash, expectedAmount, expectedNonce, clientIp) {
const publicClient = createPublicClient({
chain: CHAIN,
transport: http(),
});
const tx = await publicClient.getTransaction({ hash: txHash });
if (!tx) throw new Error('Transaction not found');
if (tx.to.toLowerCase() !== PAYEE.toLowerCase())
throw new Error('Wrong payee');
if (tx.value !== BigInt(expectedAmount))
throw new Error('Incorrect amount');
// Simple replay protection: expect tx.nonce >= stored nonce
// (In reality you’d check the transaction’s nonce against an account’s state)
const stored = nonces.get(clientIp) ?? 0;
if (tx.nonce < stored) throw new Error('Replay detected');
return true;
}
app.all('/summarize', async (req, res) => {
const clientIp = req.ip || req.connection.remoteAddress;
const auth = req.headers['x402-payment'];
if (!auth) {
const challenge = generateChallenge(clientIp);
res.set('X402-Payment-Requirements', JSON.stringify(challenge));
return res.status(402).send('Payment required');
}
try {
await verifyPayment(auth,
parseUnits('0.01', 6).toString(), // amount from challenge
null, // nonce checked inside verifyPayment
clientIp);
// ---- actual business logic ----
const { text } = req.body;
if (!typeof text === 'string') throw new Error('Missing text');
const summary = text.split(' ').slice(0, 10).join(' ') + '…';
res.json({ summary });
} catch (err) {
// If verification fails, re‑issue a fresh challenge
const challenge = generateChallenge(clientIp);
res.set('X402-Payment-Requirements', JSON.stringify(challenge));
return res.status(402).send(`Payment invalid: ${err.message}`);
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`x402 server listening on :${PORT}`));
What the server does
- On first request, it returns
402and a JSON challenge describing the USDC amount, payee, and a monotonic nonce. - On retry, it reads
X402-Paymentheader (the transaction hash), queries the Base RPC to confirm the transaction paid the correct amount to the correct address, and checks that the transaction nonce is not lower than the last seen nonce for that IP (a simple replay guard). - If all checks pass, it runs the dummy summarization logic and returns
200.
3.2 Client (client.js)
javascript
// client.js
import { createPublicClient, http, parseUnits, zeroAddress } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { base } from 'viem/chains';
import axios from 'axios';
// Replace with your agent's private key (NEVER commit this!)
const PRIVATE_KEY = '0xyour_private_key_here';
const account = privateKeyToAccount(PRIVATE_KEY);
const publicClient = createPublicClient({
chain: base,
transport: http(),
});
const SERVER = 'http://localhost:3000';
async function fetchChallenge() {
const { data, headers } = await axios.get(`${SERVER}/summarize`, {
validateStatus: () => true, // we handle 402 ourselves
});
if (headers['x402-payment-requirements']) {
return JSON.parse(headers['x402-payment-requirements']);
}
throw new Error('Unexpected response: no challenge header');
}
async function payAndRetry(challenge, payload) {
// Build a minimal USDC transfer (ERC‑20 `transfer` calldata)
const usdcAddress = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const amount = BigInt(challenge.amount); // already in base units (6 decimals)
const data = `0xa9059cbb${zeroAddress.slice(2)}${account.address.slice(2).padStart(64, '0')}${amount.toString(16).padStart(64, '0')}`;
const txHash = await publicClient.sendTransaction({
account,
to: usdcAddress,
value: 0n,
data: data as `0x${string}`,
maxFeePerGas: BigInt(challenge.maxFeePerGas),
maxPriorityFeePerGas: BigInt(challenge
Top comments (0)