x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
Why a new status code?
HTTP already defines a range of client‑error codes (4xx). The 402 Payment Required status was reserved in the original spec but never widely used. Recent work in the “x402” proposal repurposes it for micropayments that travel inside the HTTP request/response cycle. The idea is simple:
- A server that wants to charge for a resource replies 402 with a payment‑request payload.
- The client (here, an autonomous AI agent) reads that payload, constructs a blockchain transaction that satisfies the request, and retries the original request with a payment proof attached in a header.
- The server verifies the proof, and if valid, returns the requested data with a 200 OK.
Because the payment flow lives in HTTP, no extra RPC layer or custom protocol is needed—any HTTP client can participate, and the agent can stay completely stateless between calls.
Core components
| Component | Role |
|---|---|
| Resource server | Exposes an endpoint that may return 402. Holds a public key (or contract address) to verify payments. |
| Payment request | JSON placed in the X-Payment-Request header of a 402 response. Contains: amount, token contract, chain ID, recipient, nonce, and expiry. |
| Payment proof | Signed transaction (or receipt) placed in the X-Payment-Proof header of the retried request. The server checks the signature, nonce, and that the transaction succeeded on‑chain. |
| Wallet / signer | Holds the agent’s private key; used to build and sign the payment transaction. |
| Blockchain RPC | Used only for verification (read‑only calls) and to submit the payment transaction. No persistent connection is required. |
All of this can be implemented with a few lines of code; the heavy lifting is done by existing Ethereum‑compatible libraries.
Server side (Node.js/Express)
// server.js
import express from 'express';
import { ethers } from 'ethers';
import crypto from 'crypto';
const app = express();
const PORT = 3000;
// Configuration – in practice load from env or a secret manager
const RECIPIENT = '0xRecipientAddress…'; // USDC on Base
const USDC_CONTRACT = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // Base USDC
const CHAIN_ID = 8453; // Base
const PROVIDER_URL = 'https://base.mainnet.rpc.cloud'; // read‑only RPC
const PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY; // server’s hot wallet for refunds (optional)
const provider = new ethers.JsonRpcProvider(PROVIDER_URL);
const usdc = new ethers.Contract(
USDC_CONTRACT,
['function balanceOf(address) view returns (uint256)',
'function transfer(address,uint256) returns (bool)'],
provider
);
// Helper: generate a payment request payload
function makePaymentRequest(amountUSDC, nonce) {
// amount in smallest USDC units (6 decimals)
const amountWei = ethers.parseUnits(amountUSDC.toString(), 6);
return {
amount: amountWei.toString(),
token: USDC_CONTRACT,
chainId: CHAIN_ID,
recipient: RECIPIENT,
nonce: nonce.toString(),
expiry: Math.floor(Date.now() / 1000) + 300, // 5 min window
};
}
// Middleware to verify a payment proof
async function verifyPayment(req, res, next) {
const proofHeader = req.headers['x-payment-proof'];
if (!proofHeader) return res.status(400).send('Missing payment proof');
let proof;
try {
proof = JSON.parse(proofHeader);
} catch {
return res.status(400).send('Invalid payment proof JSON');
}
// Basic sanity checks
if (proof.chainId !== CHAIN_ID.toString()) return res.status(400).send('Wrong chain');
if (proof.token.toLowerCase() !== USDC_CONTRACT.toLowerCase()) return res.status(400).send('Wrong token');
if (proof.recipient.toLowerCase() !== RECIPIENT.toLowerCase()) return res.status(400).send('Wrong recipient');
// Re‑construct the transaction from the signature
const tx = {
to: proof.token,
data: usdc.interface.encodeFunctionData('transfer', [proof.recipient, proof.amount]),
value: 0,
nonce: parseInt(proof.nonce),
chainId: parseInt(proof.chainId),
};
// Recover signer address from signature
const txHash = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(
['bytes'], [ethers.RLP.encode(tx)]
));
const signerAddr = ethers.recoverAddress(txHash, proof.signature);
if (signerAddr.toLowerCase() !== proof.sender.toLowerCase())
return res.status(401).send('Signature mismatch');
// Optional: check that tx was mined and succeeded (read‑only)
const receipt = await provider.getTransactionReceipt(proof.txHash);
if (!receipt || receipt.status !== 1)
return res.status(402).send('Payment not confirmed');
// Nonce replay protection – in a real service you’d store used nonces in Redis/DynamoDB
// For demo we just accept any nonce; production must enforce uniqueness.
req.paymentVerified = true;
next();
}
// Example protected resource
app.get('/ai/summarize', verifyPayment, async (req, res) => {
const { text } = req.query;
if (!text) return res.status(400).send('Missing text');
// Stub: call an LLM (omitted for brevity)
const summary = text.slice(0, 120) + '…'; // pretend we summarized
res.json({ summary });
});
// If the client hasn’t paid, return 402 with a payment request
app.get('/ai/summarize', (req, res) => {
// In practice you’d check for a valid payment header first; omitted here for brevity
const nonce = crypto.randomInt(0, 2**32);
const paymentReq = makePaymentRequest('0.01', nonce); // $0.01 USDC
res.set('X-Payment-Request', JSON.stringify(paymentReq));
return res.status(402).send('Payment required');
});
app.listen(PORT, () => console.log(`Server listening on :${PORT}`));
What the server does
- On the first request, if no
X-Payment-Proofheader is present, it returns 402 and includes a payment request inX-Payment-Request. - When the client retries with a proof, the
verifyPaymentmiddleware:- Parses the proof.
- Re‑creates the transaction data.
- Recovers the signer address from the supplied signature.
- Checks that the transaction exists on‑chain and succeeded.
- Allows the request to reach the handler only after verification passes.
Client side (Python agent)
python
# agent.py
import json
import time
import requests
from eth_account import Account
from eth_account.messages import encode_defunct
from web3 import Web3
# Configuration
AGENT_PRIVATE_KEY = "0xYOUR_PRIVATE_KEY"
AGENT_ADDRESS = Account.from_key(AGENT_PRIVATE_KEY).address
USDC_CONTRACT = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
RECIPIENT = "0xRecipientAddress…"
CHAIN_ID = 8453
RPC_URL = "https://base.mainnet.rpc.cloud"
PROVIDER = Web3(Web3.HTTPProvider(RPC_URL))
USDC_ABI = [
{"constant":False,"inputs":[{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],
"name":"transfer","outputs":[{"name":"","type":"bool"}],"type":"function"}
]
USDC = PROVIDER.eth.contract(address=USDC_CONTRACT, abi=USDC_ABI)
def build_payment_tx(amount_usdc, nonce):
"""Return raw transaction dict for USDC transfer."""
amount_wei = int(amount_usdc * 1e6) # USDC has 6 decimals
txn = USDC.functions.transfer(
RECIPIENT,
amount_wei
).build_transaction({
"chainId": CHAIN_ID,
"from": AGENT_ADDRESS,
"nonce": nonce,
"value": 0,
})
return txn
def sign_tx(txn):
signed = Account.sign_transaction(txn, AGENT_PRIVATE_KEY)
return signed.rawTransaction.hex()
def fetch_with_payment(url, params=None):
headers = {}
resp = requests.get(url, params=params, headers=headers)
if resp.status_code == 402:
# Parse payment request
pay_req = json.loads(resp.headers["X-Payment-Request"])
amount_usdc = int(pay
Top comments (0)