x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
Published on dev.to
Why Micropayments Matter for Autonomous Agents
AI agents often need to call external services—LLM APIs, data feeds, compute kernels—on a per‑use basis. Traditional billing (API keys, monthly subscriptions) forces agents to either over‑provision or maintain complex credential‑rotation logic. An HTTP‑native approach lets the agent treat payment as just another header in the request flow, keeping the interaction stateless and easy to audit.
The x402 proposal does exactly that: it repurposes the HTTP 402 Payment Required status code to signal that a resource is behind a pay‑wall, then defines a minimal on‑chain handshake (USDC on Base) to settle the fee before the server returns the payload.
Below we walk through the protocol, show a minimal implementation in Node.js/Express, and discuss the practical trade‑offs you’ll hit when you try to run it in production.
The x402 Flow in Three Steps
- Client request – The agent sends a normal GET/POST to the service endpoint without any payment data.
-
Server response – If the request is not paid, the server replies with 402 Payment Required and a
Pay-Payloadheader that contains:-
token: the ERC‑20 contract address (USDC on Base) -
chainId: 8453 (Base) -
amount: the price in the token’s smallest unit (wei‑scaled) -
receiver: the service’s wallet address -
nonce: a monotonic counter to prevent replay attacks -
signature: an ECDSA signature (over the concatenated fields) made by the service’s private key, proving the quote is genuine.
-
-
Client payment & retry – The agent verifies the signature, builds an ERC‑20
transferFromtransaction (approving the spender if needed), sends it to the Base RPC, waits for confirmation, then retries the original request adding anAuthorization: Bearer <txHash>header. The server checks that the transaction transferred the exact amount to its address, validates the nonce hasn’t been used, and finally returns the requested resource with a 200 OK.
Because the payment is settled on‑chain before the server does any work, the agent never trusts the service to “hold” funds, and the service never needs to maintain per‑client balances.
Minimal Server Implementation (Node.js + Express)
// server.js
require('dotenv').config();
const express = require('express');
const { ethers } = require('ethers');
const app = express();
app.use(express.json());
// ---- CONFIG -------------------------------------------------
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; // Base USDC
const CHAIN_ID = 8453;
const RECEIVER = process.env.SERVICE_WALLET; // must match the private key below
const PRIVATE_KEY = process.env.PRIVATE_KEY; // service's EOA key
const provider = new ethers.JsonRpcProvider('https://base.mainnet.rpc.cloud');
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const usdcAbi = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)",
"function decimals() view returns (uint8)",
"function approve(address spender, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)"
];
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, provider);
// -----------------------------------------------------------
let nonce = 0; // simple in‑memory nonce; replace with Redis/DynamoDB in prod
// Helper: sign the quote
async function signQuote(amount) {
const payload = ethers.solidityPacked(
['address', 'uint256', 'uint256', 'address', 'uint256'],
[USDC_ADDRESS, CHAIN_ID, amount, RECEIVER, nonce]
);
const hash = ethers.hashMessage(payload);
const sig = await wallet.signMessage(ethers.getBytes(hash));
return { payload, hash, sig };
}
// Middleware: enforce payment
async function requirePayment(req, res, next) {
const price = BigInt(req.headers['x-price'] ?? 0); // set by route
if (price === 0n) return next(); // free endpoint
// Verify if request already carries a paid tx hash
const txHash = req.headers['authorization']?.replace(/^Bearer /i, '');
if (txHash) {
const tx = await provider.getTransaction(txHash);
if (!tx) return res.status(400).send('Invalid tx hash');
const receipt = await provider.waitForTransaction(txHash);
if (receipt.status !== 1) return res.status(402).send('Tx failed');
// Check that USDC moved from caller to us
const transferEvent = usdc.interface.parseLog(receipt.logs.find(l =>
l.address.toLowerCase() === USDC_ADDRESS.toLowerCase() &&
l.topics[0] === ethers.id('Transfer(address,address,uint256)')
));
if (!transferEvent) return res.status(402).send('No USDC transfer');
const [from, to, value] = transferEvent.args;
if (to.toLowerCase() !== RECEIVER.toLowerCase() || value !== price)
return res.status(402).send('Incorrect amount or receiver');
// Replay protection: ensure nonce matches
const usedNonce = Number(req.headers['x-nonce'] ?? 0);
if (usedNonce !== nonce) return res.status(402).send('Stale nonce');
// Mark nonce as used (in production use a set with TTL)
nonce++;
return next();
}
// Not paid yet → return 402 with quote
const { payload, hash, sig } = await signQuote(Number(price));
res.set({
'Pay-Payload': ethers.toHexString(payload),
'Pay-Signature': sig,
'Pay-Nonce': String(nonce),
'Pay-Token': USDC_ADDRESS,
'Pay-ChainId': String(CHAIN_ID),
'Pay-Amount': String(price)
});
return res.status(402).send('Payment required');
}
// Example protected route
app.get('/ai/complete', requirePayment, (req, res) => {
const prompt = req.query.prompt || '';
// Imagine calling an LLM here – we just echo for demo
res.json({ reply: `You said: "${prompt}"` });
});
app.listen(3000, () => console.log('x402 server listening on :3000'));
What the code does
- The
requirePaymentmiddleware checks for a Bearer token that must be the hash of a confirmed USDC transfer. - If missing, it builds a quote (
Pay-Payload) and signs it with the service’s private key. - After the agent sends the transaction, the server verifies the transfer amount, receiver, and that the nonce hasn’t been reused.
- On success, the request proceeds to the actual handler.
Minimal Agent Client (JavaScript, using ethers)
js
// agent.js
require('dotenv').config();
const { ethers } = require('ethers');
const fetch = require('node-fetch');
const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const CHAIN_ID = 8453;
const AGENT_WALLET = new ethers.Wallet(process.env.PRIVATE_KEY);
const provider = new ethers.JsonRpcProvider('https://base.mainnet.rpc.cloud');
const usdcAbi = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)",
"function decimals() view returns (uint8)",
"function approve(address spender, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)"
];
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, AGENT_WALLET.connect(provider));
async function fetchWithPayment(url, opts = {}) {
let response = await fetch(url, opts);
if (response.status !== 402) return response; // either success or other error
// Parse the quote
const payloadHex = response.headers.get('Pay-Payload');
const signature = response.headers.get('Pay-Signature');
const nonce = Number(response.headers.get('Pay-Nonce'));
const token = response.headers.get('Pay-Token');
const chainId = Number(response.headers.get('Pay-ChainId'));
const amount = BigInt(response.headers.get('Pay-Amount'));
// Verify signature (optional but recommended)
const recovered = ethers.verifyMessage(
ethers.getBytes(payloadHex),
signature
);
if (recovered.toLowerCase() !== AGENT_WALLET.address.toLowerCase())
throw new Error('Invalid quote signature');
// Approve USDC transfer if needed
const decimals = await usdc.decimals();
const amountHuman = amount / BigInt(10 ** decimals);
const allowance = await usdc.allowance(AGENT_WALLET.address, AGENT_WALLET.address);
if (allowance < amount) {
const approveTx = await usdc.approve(AGENT_WALLET.address, amount);
await approveTx.wait();
}
// Execute the transfer
const tx = await usdc.transfer(
// receiver address is encoded in
Top comments (0)