x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
Target audience: developers building autonomous AI agents who need a lightweight, on‑chain way to charge per‑call without reinventing billing infrastructure.
1. Why x402 matters for agents
AI agents frequently invoke other services—LLM endpoints, data feeds, tool wrappers—often dozens or hundreds of times per task. Traditional API‑key or subscription models add operational overhead (key rotation, usage metering, invoicing) and are poorly suited for sub‑cent pricing.
x402 is an HTTP status code (402 Payment Required) extension that lets a server signal that a request can be fulfilled only after the client presents a verifiable, on‑chain payment. The flow stays inside the HTTP request/response cycle, so agents can treat a paid call exactly like any other GET/POST: they add a header, retry on 402, and proceed when the header validates.
Key properties:
| Property | What it means for agents |
|---|---|
| ** Stateless** | No server‑side session needed; each request carries its own proof. |
| ** Atomic** | Payment verification and service execution happen in the same request; no separate settlement step. |
| ** Chain‑agnostic** | Works with any EVM‑compatible chain that supports ERC‑20 tokens (USDC on Base, Polygon, etc.). |
| ** Minimal overhead** | Only a few extra bytes (signature + nonce) added to the request header. |
2. The protocol in a nutshell
- Client sends a normal HTTP request.
-
Server checks for a valid
X402-Paymentheader.- If missing or invalid → respond 402 Payment Required with a
WWW-Authenticate‑style challenge that includes:-
price(amount in smallest token unit) -
token(ERC‑20 contract address) -
chainId -
nonce(server‑generated, prevents replay)
-
- If missing or invalid → respond 402 Payment Required with a
-
Client builds a payment proof:
- Assemble the message:
keccak256(abi.encodePacked(price, token, chainId, nonce, requestBodyHash)) - Sign it with an EOA or smart‑wallet private key (
eth_sign). - Encode the signature (v, r, s) and the signer address into the
X402-Paymentheader.
- Assemble the message:
- Server verifies the signature, confirms the signer holds enough balance (or relies on the mempool if using ERC‑4337 paymaster), increments its internal nonce, and processes the request.
Because the proof is tied to the exact request body (via its hash), replay attacks are prevented without extra state beyond a nonce.
3. Minimal working example (Node.js/Express)
Below is a self‑contained server that protects a simple /sum endpoint. The client snippet shows how an agent would add the required header.
3.1 Server (server.js)
// server.js
require('dotenv').config();
const express = require('express');
const { ethers } = require('ethers');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// Configuration – adjust for your environment
const TOKEN_ADDRESS = process.env.USDC_ADDRESS; // e.g. 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 (Base)
const CHAIN_ID = Number(process.env.CHAIN_ID) || 8453; // Base mainnet
const PRICE_PER_CALL = ethers.utils.parseUnits('0.01', 6); // $0.01 USDC (6 decimals)
// Simple in‑memory nonce store (replace with Redis/DynamoDB in prod)
const nonces = new Map();
/**
* Helper: build the challenge header for a 402 response
*/
function makeChallenge(req) {
const nonce = ethers.utils.randomBytes(16).toString('hex');
nonces.set(req.ip, nonce); // naïve per‑IP nonce; improve for scale
return {
'X402-Challenge': JSON.stringify({
price: PRICE_PER_CALL.toString(),
token: TOKEN_ADDRESS,
chainId: CHAIN_ID,
nonce,
}),
};
}
/**
* Middleware that validates the X402-Payment header
*/
function x402Middleware(req, res, next) {
const auth = req.headers['x402-payment'];
if (!auth) {
return res.status(402).set(makeChallenge(req)).json({ error: 'payment required' });
}
try {
const { signer, signature, bodyHash } = JSON.parse(auth);
const nonce = nonces.get(req.ip);
if (!nonce) throw new Error('nonce missing or expired');
// Re‑compute the hash of the request body (empty body => hash of empty string)
const computedHash = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
['bytes'],
[ethers.utils.keccak256(ethers.utils.toUtf8Bytes(JSON.stringify(req.body)))]
)
);
if (computedHash !== bodyHash) throw new Error('body hash mismatch');
// Build the signed message
const message = ethers.utils.solidityKeccak256(
['uint256', 'address', 'uint256', 'bytes32', 'bytes32'],
[
PRICE_PER_CALL,
TOKEN_ADDRESS,
CHAIN_ID,
ethers.utils.keccak256(ethers.utils.toUtf8Bytes(nonce)),
bodyHash,
]
);
const recovered = ethers.utils.recoverAddress(message, signature);
if (recovered.toLowerCase() !== signer.toLowerCase())
throw new Error('signature mismatch');
// Optional: check token balance (requires an RPC call)
// For demo we assume the signer has funded a escrow contract ahead of time.
// In production you’d query the ERC20 balance or rely on a paymaster.
// Consume nonce to prevent replay
nonces.delete(req.ip);
next();
} catch (e) {
console.warn('x402 validation failed:', e.message);
return res.status(402).set(makeChallenge(req)).json({ error: 'invalid payment' });
}
}
// Protected endpoint
app.post('/sum', x402Middleware, (req, res) => {
const { a, b } = req.body;
if (typeof a !== 'number' || typeof b !== 'number') {
return res.status(400).json({ error: 'expected numbers a and b' });
}
res.json({ result: a + b });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`x402 demo listening on :${PORT}`));
Explanation of the server code
- The middleware checks for
X402-Payment. If missing, it returns 402 with a challenge that includes price, token, chain ID, and a freshly generated nonce. - The client must sign a deterministic message that binds the nonce, price, token, chain ID, and a hash of the request body.
- Verification recovers the signer address and compares it to the supplied
signer. - After a successful verification the nonce is removed (preventing replay) and the handler runs.
Trade‑off: Storing nonces in a per‑IP
Mapworks for a demo but does not scale. In production you’d use a shared, low‑latency store (Redis, DynamoDB) with TTL expiration, or rely on a smart‑contract‑based nonce (e.g., ERC‑4337 entry point) to avoid any off‑chain state.
3.2 Client snippet (agent side)
javascript
// agent.js – minimal fetch wrapper that handles 402 challenges
import { ethers } from 'ethers';
const provider = new ethers.JsonRpcProvider(process.env.BASE_RPC); // e.g. https://base.mainnet.rpc.dev
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
const USDC = process.env.USDC_ADDRESS; // same as server
const CHAIN_ID = 8453;
const PRICE = ethers.utils.parseUnits('0.01', 6); // must match server
async function signedFetch(url, options = {}) {
let resp = await fetch(url, options);
// If we get 402, we need to build and attach a payment proof
if (resp.status === 402) {
const challenge = resp.headers.get('x402-challenge');
if (!challenge) throw new Error('402 without challenge');
const { price, token, chainId, nonce } = JSON.parse(challenge);
if (price !== PRICE.toString() || token.toLowerCase() !== USDC.toLowerCase() || chainId !== CHAIN_ID) {
throw new Error('Challenge parameters mismatch');
}
// Hash of the request body (empty for GET, otherwise JSON.stringify)
const bodyHash = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
['bytes'],
[ethers.utils.keccak256(ethers.utils.toUtf8Bytes(JSON.stringify(options.body || '')))]
)
);
const message = ethers.utils.solidityKeccak256(
['uint256', 'address', 'uint256', 'bytes32', 'bytes32'],
[
ethers.BigNumber.from(price),
token,
chainId,
ethers.utils.keccak256(ethers.utils.toUtf8Bytes(nonce)),
bodyHash,
]
);
const signature = await wallet.signMessage(ethers.utils.arrayify(message));
const sigObj = ethers.utils.splitSignature(signature);
const paymentProof = JSON.stringify({
signer: wallet.address,
signature: {
v: sigObj.v,
Top comments (1)
One thing worth making explicit in a follow-up: the middleware in section 3 verifies a signature but never moves money. The balance check is commented out and no escrow or transfer call happens, so as written the endpoint accepts a proof that cost the caller nothing — fine for illustrating the handshake, but the "Atomic" property in your table only holds if verification includes an actual ERC-20 transfer. That's what the real x402 flow does with EIP-3009
transferWithAuthorization— settlement happens inside the request, so a 402-retry can't be replayed for free.The nonce store would also work against you in practice: agents behind corporate NAT or a shared egress proxy all present the same IP, so a per-IP nonce serializes or collides them. Returning a per-challenge nonce in the 402 response and echoing it back in the payment header removes the per-IP bookkeeping entirely and lets you expire challenges by timestamp instead of Map deletes.
Curious how you'd handle partial failure after
next()— if the handler 500s once the nonce is consumed, the caller has paid and has no receipt to retry with. Is there an intended replay window for the same payment proof?