x402 Explained: HTTP‑Native Micropayments for AI Agents (With Real Code)
Target audience: developers who are building autonomous AI agents and need a lightweight, standards‑based way to charge per‑API call without rebuilding a billing system from scratch.
1. Why look at x402?
Autonomous agents frequently call third‑party services (LLM inference, data feeds, tool executions). Traditional API‑key or subscription models force you to:
- Maintain a separate billing backend.
- Handle invoicing, refunds, and usage‑quota tracking.
- Deal with latency introduced by round‑trips to a payment processor.
The x402 extension repurposes the HTTP 402 Payment Required status code to signal that a request can be satisfied only after a micropayment is attached to the request itself. Because the payment is carried in HTTP headers, the agent can settle the cost in the same round‑trip that fetches the response—no extra round‑trip to a billing service is needed.
The spec is still a draft (IETF draft‑ietf‑httpapi‑x402‑00), but several testnet implementations exist, and the mechanics are simple enough to prototype today.
2. The x402 flow in a nutshell
| Step | Who does what? | HTTP details |
|---|---|---|
| 1. Request | Agent sends a normal GET/POST to the protected resource. | No Payment header yet. |
| 2. Challenge | Server replies 402 Payment Required with a Payment-Request header that describes the amount, asset, and destination. |
Payment-Request: amount="0.01", asset="USDC", network="base", destination="0xAbc…" |
| 3. Payment | Agent constructs a signed payment (usually an ERC‑20 transfer) and includes it in a Payment header on a retry. |
Payment: tx="0x1234…", signature="0xabcd…", plus optional Payment-Id for idempotency. |
| 4. Verification | Server validates the signature, checks that the transaction actually moved the required amount, then returns 200 OK with the payload. | If anything fails → another 402 (or 400 Bad Request). |
Because the payment is a single blockchain transaction, the agent only needs to know how to sign and submit an ERC‑20 transfer—no custom escrow contract is required.
3. Minimal working example (Node.js + Express)
Below is a self‑contained snippet that shows both sides: a server that protects an LLM‑like endpoint with x402, and a client agent that pays and retries automatically.
Note: This uses the Base Sepolia testnet and USDC on Base. Replace RPC URLs, private keys, and contract addresses with your own values for production.
3.1 Server side (protecting the resource)
// server.js
import express from 'express';
import { ethers } from 'ethers';
import cors from 'cors';
const app = express();
app.use(cors());
app.use(express.json());
// Configuration – replace with your own values
const USDC_ADDRESS = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; // Base Sepolia USDC
const RECIPIENT = '0xYourAgentWalletHere'; // where payments go
const PRICE_USDC = ethers.parseUnits('0.01', 6); // $0.01 = 0.01 USDC (6 decimals)
const provider = new ethers.JsonRpcProvider('https://sepolia.base.org');
const wallet = new ethers.Wallet(process.env.SERVER_PRIVATE_KEY, provider);
const usdcAbi = ["function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)"];
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, wallet);
/**
* Middleware that enforces x402 payment.
* If the request lacks a valid Payment header, we respond 402 with a Payment-Request.
*/
async function requirePayment(req, res, next) {
const auth = req.headers['payment'];
if (!auth) {
// No payment yet → ask for it
return res.status(402)
.set('Payment-Request', JSON.stringify({
amount: ethers.formatUnits(PRICE_USDC, 6),
asset: 'USDC',
network: 'base',
destination: RECIPIENT
}))
.json({ error: 'payment required' });
}
// Expect JSON: { tx: "0x...", signature: "0x..." }
let payload;
try { payload = JSON.parse(auth); } catch { return res.status(400).json({error: 'malformed Payment header'}); }
const { tx, signature } = payload;
if (!tx || !signature) return res.status(400).json({error: 'missing tx or signature'});
// Recover signer from signature (EIP‑191)
const messageHash = ethers.keccak256(ethers.toUtf8Bytes(tx));
const signerAddr = ethers.recoverAddress(messageHash, signature);
// Basic replay protection: check that tx hasn't been seen before (in‑memory set for demo)
if (global.seenTx.has(tx)) return res.status(402).json({error: 'tx already used'});
global.seenTx.add(tx);
// Verify that the transaction actually transferred enough USDC
// In a real service you would query a block explorer or run a node;
// here we simulate by trusting the signer (NOT safe for production!).
// For illustration we just check that the signer is the expected payer:
if (signerAddr.toLowerCase() !== req.headers['x-payer-address'].toLowerCase()) {
return res.status(402).json({error: 'signature does not match declared payer'});
}
// OPTIONAL: actually pull the tx from an RPC and verify amount.
// For brevity we skip that step – see trade‑offs section.
// If we got here, payment is considered good.
req.payer = signerAddr;
next();
}
// Protected endpoint – pretend it's an LLM completion
app.post('/v1/complete', requirePayment, async (req, res) => {
const { prompt } = req.body;
// Dummy response; replace with actual model call
const answer = `Echo: ${prompt}`;
res.json({ answer });
});
// Global set to avoid replays (only for demo)
global.seenTx = new Set();
app.listen(3000, () => console.log('x402 demo server listening on :3000'));
What the server does
-
No Payment header → returns
402with aPayment-RequestJSON string. - Payment header present → attempts to recover the signer from an EIP‑191 signature over the raw transaction hash.
- (Demo) trusts the signer; a production implementation would:
- Fetch the transaction from an RPC or block explorer.
- Confirm the
transfercall moved at leastPRICE_USDCUSDC toRECIPIENT. - Enforce a nonce or timestamp to prevent replay attacks.
3.2 Client side (agent that pays and retries)
javascript
// agent.js
import { ethers } from 'ethers';
import axios from 'axios';
// Replace with your agent's wallet (must hold USDC on Base Sepolia)
const AGENT_PRIVATE_KEY = process.env.AGENT_PRIVATE_KEY;
const agentWallet = new ethers.Wallet(AGENT_PRIVATE_KEY,
new ethers.JsonRpcProvider('https://sepolia.base.org'));
const USDC_ADDRESS = '0x036CbD53842c5426634e7929541eC2318f3dCF7e';
const usdcAbi = ["function transfer(address to, uint256 amount) returns (bool)"];
const usdc = new ethers.Contract(USDC_ADDRESS, usdcAbi, agentWallet);
const SERVER = 'http://localhost:3000';
/**
* Helper: signs a plain string with EIP‑191 (personal_sign) so the server can recover the address.
*/
function signMessage(msg) {
return agentWallet.signMessage(ethers.getBytes(msg));
}
/**
* Attempts a request, handling 402 challenges by constructing and sending a payment.
*/
async function paidPost(endpoint, body) {
let attempts = 0;
while (true) {
const resp = await axios.post(`${SERVER}${endpoint}`, body, {
headers: {
// Tell the server who we claim to be (used only for demo verification)
'x-payer-address': agentWallet.address
}
});
if (resp.status !== 402) return resp.data; // success or other error
// ----- 402 handling -----
const raw = resp.headers['payment-request'];
let req;
try { req = JSON.parse(raw); } catch { throw new Error('Invalid
Top comments (0)